Principiile SOLID sunt cinci principii ale proiectării clasei orientate pe obiecte. Acestea sunt un set de reguli și cele mai bune practici de urmat în timp ce proiectați o structură de clasă.

Aceste cinci principii ne ajută să înțelegem necesitatea anumitor modele de proiectare și arhitectură software în general. Deci, cred că este un subiect pe care fiecare dezvoltator ar trebui să îl învețe.

Acest articol vă va învăța tot ce trebuie să știți pentru a aplica principiile SOLID proiectelor dvs.

Vom începe prin a arunca o privire asupra istoriei acestui termen. Apoi vom intra în detaliile minunate – de ce și cum este fiecare principiu – creând un design de clasă și îmbunătățindu-l pas cu pas.

Deci, ia o ceașcă de cafea sau ceai și să sărim direct!

fundal

Principiile SOLID au fost introduse pentru prima dată de celebrul informatician Robert J. Martin (alias Unchiul Bob) în al său hârtie în 2000. Dar acronimul SOLID a fost introdus mai târziu de Michael Feathers.

Unchiul Bob este, de asemenea, autorul cărților bestseller Cod curat și Arhitectură curată, și este unul dintre participanții la „Alianța Agilă”.

Prin urmare, nu este o surpriză faptul că toate aceste concepte de codificare curată, arhitectură orientată obiect și modele de design sunt cumva conectate și complementare unele cu altele.

Toate au același scop:

„Pentru a crea cod de înțeles, lizibil și testabil, pe care mulți dezvoltatori pot lucra în colaborare.”

Să ne uităm la fiecare principiu unul câte unul. Urmând acronimul SOLID, acestea sunt:

  • Sprincipiul responsabilității
  • Ostilou-Principiu închis
  • Liskov Principiul înlocuirii
  • Eunterface Principiul de segregare
  • Dprincipiul inversiunii ependenței

Principiul de responsabilitate unică

Principiul unic de responsabilitate afirmă că o clasă ar trebui să facă un lucru și, prin urmare, ar trebui să aibă un singur motiv pentru a se schimba.

Pentru a afirma acest principiu mai tehnic: doar o schimbare potențială (logica bazei de date, logica de înregistrare și așa mai departe) în specificațiile software-ului ar trebui să poată afecta specificațiile clasei.

Aceasta înseamnă că, dacă o clasă este un container de date, cum ar fi o clasă Book sau o clasă Student, și are câteva câmpuri referitoare la entitatea respectivă, ar trebui să se schimbe numai atunci când schimbăm modelul de date.

Respectarea principiului responsabilității unice este importantă. În primul rând, deoarece multe echipe diferite pot lucra la același proiect și pot edita aceeași clasă din diferite motive, acest lucru ar putea duce la module incompatibile.

În al doilea rând, facilitează controlul versiunilor. De exemplu, să spunem că avem o clasă de persistență care gestionează operațiunile bazei de date și vedem o schimbare în acel fișier în comitetele GitHub. Urmând SRP, vom ști că este legat de stocare sau lucruri legate de baze de date.

Conflictele de îmbinare sunt un alt exemplu. Ele apar atunci când diferite echipe schimbă același fișier. Dar dacă se respectă SRP, vor apărea mai puține conflicte – fișierele vor avea un singur motiv de schimbare, iar conflictele care există vor fi mai ușor de rezolvat.

Capcane comune și anti-tipare

În această secțiune vom analiza câteva greșeli frecvente care încalcă principiul responsabilității unice. Apoi vom vorbi despre câteva modalități de a le remedia.

Vom analiza codul pentru un program simplu de facturare a librăriei, ca exemplu. Să începem prin a defini o clasă de carte pe care să o folosim în factura noastră.

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

Aceasta este o clasă simplă de carte cu câteva câmpuri. Nimic extraordinar. Nu fac câmpuri private, astfel încât să nu avem nevoie să ne ocupăm de getters și seteri și să ne putem concentra în schimb pe logică.

Acum să creăm clasa de factură care va conține logica pentru crearea facturii și calcularea prețului total. Deocamdată, presupuneți că librăria noastră vinde doar cărți și nimic altceva.

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
	}

}

Iată clasa noastră de facturare. De asemenea, conține câteva câmpuri despre facturare și 3 metode:

  • calculateTotal metoda, care calculează prețul total,
  • printInvoice , care ar trebui să tipărească factura pentru consolă și
  • saveToFile metodă, responsabilă cu scrierea facturii într-un fișier.

Înainte de a citi următorul paragraf, ar trebui să vă acordați o secundă să vă gândiți la ce este în neregulă cu acest design de clasă.

Ok, deci ce se întâmplă aici? Clasa noastră încalcă principiul responsabilității unice în mai multe moduri.

Prima încălcare este printInvoice care conține logica noastră de imprimare. SRP afirmă că clasa noastră ar trebui să aibă un singur motiv de schimbare și că acest motiv ar trebui să fie o modificare a calculului facturii pentru clasa noastră.

Dar în această arhitectură, dacă am dori să schimbăm formatul de imprimare, ar trebui să schimbăm clasa. Acesta este motivul pentru care nu ar trebui să avem o logică de tipărire amestecată cu logica de afaceri din aceeași clasă.

Există o altă metodă care încalcă SRP în clasa noastră: saveToFile metodă. De asemenea, este o greșeală extrem de frecventă amestecarea logicii de persistență cu logica de afaceri.

Nu vă gândiți doar la scrierea într-un fișier – ar putea fi salvarea într-o bază de date, efectuarea unui apel API sau alte lucruri legate de persistență.

Așadar, cum putem remedia această funcție de imprimare, vă puteți întreba.

Putem crea noi clase pentru logica noastră de tipărire și persistență, așa că nu va mai trebui să modificăm clasa facturilor în aceste scopuri.

Creăm 2 clase, InvoicePrinter și Persistență factură, și mutați metodele.

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

Acum structura noastră de clasă respectă principiul de responsabilitate unică și fiecare clasă este responsabilă pentru un aspect al aplicației noastre. Grozav!

Principiul deschis-închis

Principiul deschis-închis necesită acest lucru clasele ar trebui să fie deschise pentru extensie și închise pentru modificări

Modificarea înseamnă schimbarea codului unei clase existente, iar extensia înseamnă adăugarea de noi funcționalități.

Deci, ceea ce vrea să spună acest principiu este: ar trebui să putem adăuga noi funcționalități fără a atinge codul existent pentru clasă. Acest lucru se datorează faptului că ori de câte ori modificăm codul existent, ne asumăm riscul de a crea bug-uri potențiale. Deci, ar trebui să evităm atingerea codului de producție testat și fiabil (în cea mai mare parte), dacă este posibil.

Dar cum vom adăuga noi funcționalități fără a atinge clasa, vă puteți întreba. De obicei se face cu ajutorul interfețelor și a claselor abstracte.

Acum, că am acoperit elementele de bază ale principiului, să îl aplicăm aplicației noastre Factură.

Să presupunem că șeful nostru a venit la noi și a spus că vor ca facturile să fie salvate într-o bază de date, astfel încât să le putem căuta cu ușurință. Credem bine, acesta este șeful ușor, dă-mi doar o secundă!

Creăm baza de date, ne conectăm la aceasta și adăugăm o metodă de salvare a noastră Factură Persistență clasă:

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

Din păcate, noi, ca dezvoltator leneș pentru magazinul de cărți, nu am proiectat cursurile pentru a fi ușor extensibile în viitor. Deci, pentru a adăuga această caracteristică, am modificat fișierul Factură Persistență clasă.

Dacă proiectul nostru de clasă ar respecta principiul Deschis-Închis, nu ar fi nevoie să schimbăm această clasă.

Deci, în calitate de dezvoltator leneș, dar inteligent pentru librărie, vedem problema de proiectare și decidem să refactorizăm codul pentru a respecta principiul.

interface InvoicePersistence {

    public void save(Invoice invoice);
}

Schimbăm tipul de Factură Persistență la interfață și adăugați o metodă de salvare. Fiecare clasă de persistență va implementa această metodă de salvare.

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

Deci structura noastră de clasă arată acum:

Principiile SOLID ale programarii orientate pe obiecte explicate in engleza

Acum logica noastră de persistență este ușor de extins. Dacă șeful nostru ne cere să adăugăm o altă bază de date și să avem 2 tipuri diferite de baze de date precum MySQL și MongoDB, putem face asta cu ușurință.

S-ar putea să credeți că am putea crea mai multe clase fără o interfață și le putem adăuga o metodă de salvare la toate.

Dar să presupunem că ne extindem aplicația și că avem mai multe clase de persistență precum Factură Persistență, BookPersistence și creăm un PersistenceManager clasă care gestionează toate clasele de persistență:

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

Acum putem trece orice clasă care implementează Factură Persistență interfață cu această clasă cu ajutorul polimorfismului. Aceasta este flexibilitatea oferită de interfețe.

Principiul de înlocuire a lui Liskov

Principiul de înlocuire Liskov afirmă că subclasele ar trebui să fie înlocuibile pentru clasele lor de bază.

Aceasta înseamnă că, având în vedere că clasa B este o subclasă a clasei A, ar trebui să putem transmite un obiect din clasa B oricărei metode care așteaptă un obiect din clasa A, iar metoda nu ar trebui să ofere nicio ieșire ciudată în acest caz.

Acesta este comportamentul așteptat, deoarece atunci când folosim moștenirea presupunem că clasa copil moștenește tot ce are superclasa. Clasa de copii extinde comportamentul, dar nu îl restrânge niciodată.

Prin urmare, atunci când o clasă nu respectă acest principiu, duce la niște bug-uri urâte care sunt greu de detectat.

Principiul lui Liskov este ușor de înțeles, dar greu de detectat în cod. Așadar, să ne uităm la un exemplu.

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

Avem o clasă simplă de dreptunghi și o getArea funcție care returnează aria dreptunghiului.

Acum decidem să creăm o altă clasă pentru Squares. După cum știți, un pătrat este doar un tip special de dreptunghi, în care lățimea este egală cu înălțimea.

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

Clasa noastră Square extinde clasa dreptunghiulară. Am stabilit înălțimea și lățimea la aceeași valoare în constructor, dar nu dorim ca niciun client (cineva care folosește clasa noastră în codul lor) să schimbe înălțimea sau greutatea într-un mod care poate încălca proprietatea pătrată.

Prin urmare, înlocuim seterii pentru a seta ambele proprietăți ori de câte ori una dintre ele este modificată. Dar, făcând asta, tocmai am încălcat principiul substituției Liskov.

Să creăm o clasă principală pentru a efectua teste pe getArea funcţie.

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

Testerul echipei tale tocmai a venit cu funcția de testare getAreaTest și îți spune că getArea funcția nu reușește să treacă testul pentru obiecte pătrate.

În primul test, creăm un dreptunghi în care lățimea este 2 și înălțimea este 3 și apelăm getAreaTest. Rezultatul este de 20 așa cum era de așteptat, dar lucrurile merg prost când trecem în pătrat. Acest lucru se datorează faptului că apelul la setHeight funcția din test este setarea lățimii și are ca rezultat o ieșire neașteptată.

Principiul de separare a interfeței

Segregarea înseamnă păstrarea lucrurilor separate, iar principiul de separare a interfeței este despre separarea interfețelor.

Principiul afirmă că multe interfețe specifice clientului sunt mai bune decât o interfață de uz general. Clienții nu ar trebui să fie obligați să implementeze o funcție de care nu au nevoie.

Acesta este un principiu simplu de înțeles și aplicat, așa că să vedem un exemplu.

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 {

}

Am modelat o parcare foarte simplificată. Este tipul de parcare în care plătiți o taxă orară. Acum considerați că dorim să implementăm o parcare gratuită.

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

Interfața noastră de parcare era compusă din 2 lucruri: logică legată de parcare (parcare mașină, unpark auto, obțineți capacitate) și logică legată de plată.

Dar este prea specific. Din această cauză, clasa noastră FreeParking a fost forțată să implementeze metode legate de plată care sunt irelevante. Să separăm sau să separăm interfețele.

Principiile SOLID ale programarii orientate pe obiecte explicate in engleza

Acum am separat parcarea. Cu acest nou model, putem chiar să mergem mai departe și să împărțim PaidParkingLot pentru a sprijini diferite tipuri de plăți.

Acum, modelul nostru este mult mai flexibil, extensibil, iar clienții nu trebuie să implementeze nicio logică irelevantă, deoarece oferim doar funcționalități legate de parcare în interfața parcării.

Principiul inversiunii dependenței

Principiul inversiunii dependenței afirmă că clasele noastre ar trebui să depindă de interfețe sau clase abstracte în loc de clase și funcții concrete.

În a lui articol (2000), Unchiul Bob rezumă acest principiu după cum urmează:

„Dacă OCP afirmă scopul arhitecturii OO, DIP stabilește mecanismul principal”.

Aceste două principii sunt într-adevăr legate și am aplicat acest model înainte, în timp ce discutam despre principiul deschis-închis.

Vrem ca clasele noastre să fie deschise extensiei, așa că ne-am reorganizat dependențele pentru a depinde de interfețe în loc de clase concrete. Clasa noastră PersistenceManager depinde de InvoicePersistence în loc de clasele care implementează acea interfață.

Concluzie

În acest articol, am început cu istoria principiilor SOLID și apoi am încercat să dobândim o înțelegere clară a motivelor și a modurilor fiecărui principiu. Am refactorizat chiar și o aplicație simplă de factură pentru a respecta principiile SOLID.

Vreau să vă mulțumesc că ați acordat timp pentru a citi întregul articol și sper că conceptele de mai sus sunt clare.

Vă sugerez să țineți cont de aceste principii în timp ce proiectați, scrieți și refacturați codul, astfel încât codul dvs. să fie mult mai curat, extensibil și testabil.

Dacă sunteți interesat să citiți mai multe articole de acest gen, vă puteți abona la blogului listă de discuții pentru a fi notificat atunci când public un articol nou.