I principi SOLID della programmazione orientata agli oggetti spiegati in inglese semplice

I principi SOLID sono cinque principi della progettazione di classi orientata agli oggetti. Sono un insieme di regole e best practice da seguire durante la progettazione di una struttura di classe.

Questi cinque principi ci aiutano a comprendere la necessità di determinati modelli di progettazione e architettura software in generale. Quindi credo che sia un argomento che ogni sviluppatore dovrebbe imparare.

Questo articolo ti insegnerà tutto ciò che devi sapere per applicare i principi SOLID ai tuoi progetti.

Inizieremo dando uno sguardo alla storia di questo termine. Quindi entreremo nei dettagli essenziali - il perché e il come di ciascun principio - creando un design di classe e migliorandolo passo dopo passo.

Quindi prendi una tazza di caffè o tè e saltiamo dentro!

sfondo

I principi SOLID furono introdotti per la prima volta dal famoso informatico Robert J. Martin (alias Uncle Bob) nel suo articolo nel 2000. Ma l'acronimo SOLID fu introdotto successivamente da Michael Feathers.

Lo zio Bob è anche l'autore dei libri bestseller Clean Code e Clean Architecture , ed è uno dei partecipanti alla "Agile Alliance".

Pertanto, non sorprende che tutti questi concetti di codifica pulita, architettura orientata agli oggetti e modelli di progettazione siano in qualche modo collegati e complementari tra loro.

Hanno tutti lo stesso scopo:

"Per creare codice comprensibile, leggibile e testabile su cui molti sviluppatori possono lavorare in modo collaborativo".

Diamo un'occhiata a ciascun principio uno per uno. Seguendo l'acronimo SOLID, sono:

  • L' S ingle Responsibility Principle
  • Il principio O pen-Closed
  • Il principio di sostituzione di L iskov
  • L' ho nterface segregazione Principio
  • Il D ependency Inversion Principle

Il principio di responsabilità unica

Il principio di responsabilità unica afferma che una classe dovrebbe fare una cosa e quindi dovrebbe avere un solo motivo per cambiare .

Per affermare questo principio in modo più tecnico: solo una potenziale modifica (logica del database, logica di registrazione e così via) nella specifica del software dovrebbe essere in grado di influenzare la specifica della classe.

Ciò significa che se una classe è un contenitore di dati, come una classe Book o una classe Student, e ha alcuni campi relativi a tale entità, dovrebbe cambiare solo quando si modifica il modello di dati.

È importante seguire il principio della responsabilità unica. Prima di tutto, poiché molti team diversi possono lavorare sullo stesso progetto e modificare la stessa classe per motivi diversi, questo potrebbe portare a moduli incompatibili.

In secondo luogo, semplifica il controllo della versione. Ad esempio, supponiamo di avere una classe di persistenza che gestisce le operazioni del database e di vedere una modifica in quel file nei commit di GitHub. Seguendo l'SRP, sapremo che è correlato all'archiviazione o alle cose relative al database.

I conflitti di unione sono un altro esempio. Appaiono quando squadre diverse cambiano lo stesso file. Ma se si segue l'SRP, verranno visualizzati meno conflitti: i file avranno un unico motivo per essere modificati ei conflitti esistenti saranno più facili da risolvere.

Errori comuni e anti-pattern

In questa sezione esamineremo alcuni errori comuni che violano il Principio di responsabilità unica. Quindi parleremo di alcuni modi per risolverli.

Esamineremo il codice per un semplice programma di fatturazione in libreria come esempio. Cominciamo definendo una classe libro da utilizzare nella nostra fattura.

class Book { String name; String authorName; int year; int price; String isbn; public Book(String name, String authorName, int year, int price, String isbn) { this.name = name; this.authorName = authorName; this.year = year; this.price = price; this.isbn = isbn; } } 

Questa è una semplice classe di libri con alcuni campi. Nulla di bello. Non sto rendendo i campi privati ​​in modo che non abbiamo bisogno di occuparci di getter e setter e possiamo invece concentrarci sulla logica.

Ora creiamo la classe fattura che conterrà la logica per creare la fattura e calcolare il prezzo totale. Per ora, supponi che la nostra libreria venda solo libri e nient'altro.

public class Invoice { private Book book; private int quantity; private double discountRate; private double taxRate; private double total; public Invoice(Book book, int quantity, double discountRate, double taxRate) { this.book = book; this.quantity = quantity; this.discountRate = discountRate; this.taxRate = taxRate; this.total = this.calculateTotal(); } public double calculateTotal() { double price = ((book.price - book.price * discountRate) * this.quantity); double priceWithTaxes = price * (1 + taxRate); return priceWithTaxes; } public void printInvoice() { System.out.println(quantity + "x " + book.name + " " + book.price + "$"); System.out.println("Discount Rate: " + discountRate); System.out.println("Tax Rate: " + taxRate); System.out.println("Total: " + total); } public void saveToFile(String filename) { // Creates a file with given name and writes the invoice } }

Ecco la nostra classe di fatturazione. Contiene anche alcuni campi sulla fatturazione e 3 metodi:

  • calculateTotal metodo, che calcola il prezzo totale,
  • printInvoice , che dovrebbe stampare la fattura sulla console e
  • saveToFile , responsabile della scrittura della fattura su un file.

Dovresti concederti un secondo per pensare a cosa c'è di sbagliato in questo design di classe prima di leggere il paragrafo successivo.

Ok quindi cosa sta succedendo qui? La nostra classe viola il principio di responsabilità unica in diversi modi.

La prima violazione è il metodo printInvoice , che contiene la nostra logica di stampa. L'SRP afferma che la nostra classe dovrebbe avere solo un motivo per cambiare e che il motivo dovrebbe essere un cambiamento nel calcolo della fattura per la nostra classe.

Ma in questa architettura, se volessimo cambiare il formato di stampa, dovremmo cambiare la classe. Questo è il motivo per cui non dovremmo avere logica di stampa mista a logica aziendale nella stessa classe.

C'è un altro metodo che viola l'SRP nella nostra classe: il metodo saveToFile . È anche un errore estremamente comune mescolare la logica della persistenza con la logica aziendale.

Non pensare solo in termini di scrittura su un file: potrebbe essere il salvataggio su un database, l'esecuzione di una chiamata API o altre cose relative alla persistenza.

Allora come possiamo aggiustare questa funzione di stampa, potresti chiedere.

Possiamo creare nuove classi per la nostra logica di stampa e persistenza, quindi non avremo più bisogno di modificare la classe della fattura per questi scopi.

Creiamo 2 classi, InvoicePrinter e InvoicePersistence, e spostiamo i metodi.

public class InvoicePrinter { private Invoice invoice; public InvoicePrinter(Invoice invoice) { this.invoice = invoice; } public void print() { System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $"); System.out.println("Discount Rate: " + invoice.discountRate); System.out.println("Tax Rate: " + invoice.taxRate); System.out.println("Total: " + invoice.total + " $"); } }
public class InvoicePersistence { Invoice invoice; public InvoicePersistence(Invoice invoice) { this.invoice = invoice; } public void saveToFile(String filename) { // Creates a file with given name and writes the invoice } }

Ora la nostra struttura di classe obbedisce al Principio di responsabilità unica e ogni classe è responsabile di un aspetto della nostra applicazione. Grande!

Principio aperto-chiuso

Il principio aperto-chiuso richiede che le classi siano aperte per l'estensione e chiuse alla modifica.

La modifica significa cambiare il codice di una classe esistente e l'estensione significa aggiungere nuove funzionalità.

Quindi ciò che questo principio vuole dire è: dovremmo essere in grado di aggiungere nuove funzionalità senza toccare il codice esistente per la classe. Questo perché ogni volta che modifichiamo il codice esistente, corriamo il rischio di creare potenziali bug. Quindi dovremmo evitare di toccare il codice di produzione testato e affidabile (principalmente) se possibile.

Ma come aggiungeremo nuove funzionalità senza toccare la classe, potresti chiedere. Di solito è fatto con l'aiuto di interfacce e classi astratte.

Ora che abbiamo coperto le basi del principio, appliciamolo alla nostra applicazione Fattura.

Diciamo che il nostro capo è venuto da noi e ha detto che vogliono che le fatture vengano salvate in un database in modo che possiamo cercarle facilmente. Pensiamo che vada bene, questo è un capo facile, dammi solo un secondo!

Creiamo il database, ci colleghiamo ad esso e aggiungiamo un metodo di salvataggio alla nostra classe InvoicePersistence :

public class InvoicePersistence { Invoice invoice; public InvoicePersistence(Invoice invoice) { this.invoice = invoice; } public void saveToFile(String filename) { // Creates a file with given name and writes the invoice } public void saveToDatabase() { // Saves the invoice to database } }

Sfortunatamente noi, come sviluppatori pigri per la libreria, non abbiamo progettato le classi per essere facilmente estendibili in futuro. Quindi, per aggiungere questa funzionalità, abbiamo modificato la classe InvoicePersistence .

Se il nostro progetto di classe obbedisse al principio Aperto-Chiuso non avremmo bisogno di cambiare questa classe.

Quindi, come sviluppatore pigro ma intelligente per il negozio di libri, vediamo il problema del design e decidiamo di refactoring del codice per obbedire al principio.

interface InvoicePersistence { public void save(Invoice invoice); }

Cambiamo il tipo di InvoicePersistence in Interface e aggiungiamo un metodo di salvataggio. Ogni classe di persistenza implementerà questo metodo di salvataggio.

public class DatabasePersistence implements InvoicePersistence { @Override public void save(Invoice invoice) { // Save to DB } }
public class FilePersistence implements InvoicePersistence { @Override public void save(Invoice invoice) { // Save to file } }

Quindi la nostra struttura di classe ora appare così:

Ora la nostra logica di persistenza è facilmente estendibile. Se il nostro capo ci chiede di aggiungere un altro database e avere 2 diversi tipi di database come MySQL e MongoDB, possiamo farlo facilmente.

Potresti pensare che potremmo semplicemente creare più classi senza un'interfaccia e aggiungere un metodo di salvataggio a tutte.

Ma diciamo che estendiamo la nostra app e abbiamo più classi di persistenza come InvoicePersistence , BookPersistence e creiamo una classe PersistenceManager che gestisce tutte le classi di persistenza:

public class PersistenceManager { InvoicePersistence invoicePersistence; BookPersistence bookPersistence; public PersistenceManager(InvoicePersistence invoicePersistence, BookPersistence bookPersistence) { this.invoicePersistence = invoicePersistence; this.bookPersistence = bookPersistence; } }

Ora possiamo passare qualsiasi classe che implementa l' interfaccia InvoicePersistence a questa classe con l'aiuto del polimorfismo. Questa è la flessibilità offerta dalle interfacce.

Principio di sostituzione di Liskov

The Liskov Substitution Principle states that subclasses should be substitutable for their base classes.

This means that, given that class B is a subclass of class A, we should be able to pass an object of class B to any method that expects an object of class A and the method should not give any weird output in that case.

This is the expected behavior, because when we use inheritance we assume that the child class inherits everything that the superclass has. The child class extends the behavior but never narrows it down.

Therefore, when a class does not obey this principle, it leads to some nasty bugs that are hard to detect.

Liskov's principle is easy to understand but hard to detect in code. So let's look at an example.

class Rectangle { protected int width, height; public Rectangle() { } public Rectangle(int width, int height) { this.width = width; this.height = height; } public int getWidth() { return width; } public void setWidth(int width) { this.width = width; } public int getHeight() { return height; } public void setHeight(int height) { this.height = height; } public int getArea() { return width * height; } }

We have a simple Rectangle class, and a getArea function which returns the area of the rectangle.

Now we decide to create another class for Squares. As you might know, a square is just a special type of rectangle where the width is equal to the height.

class Square extends Rectangle { public Square() {} public Square(int size) { width = height = size; } @Override public void setWidth(int width) { super.setWidth(width); super.setHeight(width); } @Override public void setHeight(int height) { super.setHeight(height); super.setWidth(height); } }

Our Square class extends the Rectangle class. We set height and width to the same value in the constructor, but we do not want any client (someone who uses our class in their code) to change height or weight in a way that can violate the square property.

Therefore we override the setters to set both properties whenever one of them is changed. But by doing that we have just violated the Liskov substitution principle.

Let's create a main class to perform tests on the getArea function.

class Test { static void getAreaTest(Rectangle r) { int width = r.getWidth(); r.setHeight(10); System.out.println("Expected area of " + (width * 10) + ", got " + r.getArea()); } public static void main(String[] args) { Rectangle rc = new Rectangle(2, 3); getAreaTest(rc); Rectangle sq = new Square(); sq.setWidth(5); getAreaTest(sq); } }

Your team's tester just came up with the testing function getAreaTest and tells you that your getArea function fails to pass the test for square objects.

In the first test, we create a rectangle where the width is 2 and the height is 3 and call getAreaTest. The output is 20 as expected, but things go wrong when we pass in the square. This is because the call to setHeight function in the test is setting the width as well and results in an unexpected output.

Interface Segregation Principle

Segregation means keeping things separated, and the Interface Segregation Principle is about separating the interfaces.

The principle states that many client-specific interfaces are better than one general-purpose interface. Clients should not be forced to implement a function they do no need.

This is a simple principle to understand and apply, so let's see an example.

public interface ParkingLot { void parkCar(); // Decrease empty spot count by 1 void unparkCar(); // Increase empty spots by 1 void getCapacity(); // Returns car capacity double calculateFee(Car car); // Returns the price based on number of hours void doPayment(Car car); } class Car { }

We modeled a very simplified parking lot. It is the type of parking lot where you pay an hourly fee. Now consider that we want to implement a parking lot that is free.

public class FreeParking implements ParkingLot { @Override public void parkCar() { } @Override public void unparkCar() { } @Override public void getCapacity() { } @Override public double calculateFee(Car car) { return 0; } @Override public void doPayment(Car car) { throw new Exception("Parking lot is free"); } }

Our parking lot interface was composed of 2 things: Parking related logic (park car, unpark car, get capacity) and payment related logic.

But it is too specific. Because of that, our FreeParking class was forced to implement payment-related methods that are irrelevant. Let's separate or segregate the interfaces.

Ora abbiamo separato il parcheggio. Con questo nuovo modello, possiamo anche andare oltre e suddividere il PaidParkingLot per supportare diversi tipi di pagamento.

Ora il nostro modello è molto più flessibile, estendibile e i clienti non hanno bisogno di implementare alcuna logica irrilevante perché forniamo solo funzionalità relative al parcheggio nell'interfaccia del parcheggio.

Principio di inversione delle dipendenze

Il principio di inversione delle dipendenze afferma che le nostre classi dovrebbero dipendere da interfacce o classi astratte invece che da classi e funzioni concrete.

Nel suo articolo (2000), lo zio Bob riassume questo principio come segue:

"Se l'OCP indica l'obiettivo dell'architettura OO, il DIP indica il meccanismo primario".

These two principles are indeed related and we have applied this pattern before while we were discussing the Open-Closed Principle.

We want our classes to be open to extension, so we have reorganized our dependencies to depend on interfaces instead of concrete classes. Our PersistenceManager class depends on InvoicePersistence instead of the classes that implement that interface.

Conclusion

In this article, we started with the history of SOLID principles, and then we tried to acquire a clear understanding of the why's and how's of each principle. We even refactored a simple Invoice application to obey SOLID principles.

I want to thank you for taking the time to read the whole article and I hope that the above concepts are clear.

I suggest keeping these principles in mind while designing, writing, and refactoring your code so that your code will be much more clean, extendable, and testable.

If you are interested in reading more articles like this, you can subscribe to my blog's mailing list to get notified when I publish a new article.