Az objektum-orientált programozás szilárd alapelvei, magyarázattal sima angol nyelven

A SOLID alapelvek az objektum-orientált osztálytervezés öt alapelve. Ezek olyan szabályok és bevált gyakorlatok, amelyeket be kell tartani az osztálystruktúra megtervezése során.

Ez az öt elv segít megérteni bizonyos tervezési minták és általában a szoftverarchitektúra szükségességét. Tehát úgy gondolom, hogy ezt a témát minden fejlesztőnek meg kell tanulnia.

Ez a cikk megtanít mindent, amit tudnia kell a SZOLID elvek alkalmazásához a projektjeiben.

Kezdjük azzal, hogy betekintünk ennek a kifejezésnek a történetébe. Ezután belemerülünk az apró-csiszolt részletekbe - az egyes elvek miértjébe és módjába - azáltal, hogy létrehozunk egy osztálytervet, és azt lépésről lépésre fejlesztjük.

Fogj hát egy csésze kávét vagy teát, és ugorjunk bele!

Háttér

A SOLID elveket először a híres számítástechnikus, Robert J. Martin (más néven Bob bácsi) vezette be 2000-ben. De a SOLID rövidítést később Michael Feathers vezette be.

Bob bácsi a Tiszta kód és a Tiszta építészet című bestseller könyvek szerzője , és az "Agilis Szövetség" egyik résztvevője.

Ezért nem meglepő, hogy a tiszta kódolás, az objektum-orientált architektúra és a tervezési minták e fogalmai valamilyen módon összekapcsolódnak és kiegészítik egymást.

Mindannyian ugyanazt a célt szolgálják:

"Érthető, olvasható és tesztelhető kód létrehozása, amelyen számos fejlesztő együttműködhet."

Nézzük az egyes elveket egyenként. A SOLID rövidítést követve:

  • A S Ingle felelősség elve
  • Az O tollal zárt elv
  • Az L iskov helyettesítés elve
  • Az I nterface szegregáció elve
  • A D ependencia inverzió elve

Az egységes felelősség elve

Az Egységes Felelősség Elve kimondja, hogy egy osztálynak egyetlen dolgot kell tennie, és ezért csak egyetlen oka lehet a változásra .

Ennek az elvnek a technikai szempontból történő megfogalmazása: A szoftver specifikációjában csak egy lehetséges változás (adatbázis-logika, naplózási logika stb.) Képes befolyásolni az osztály specifikációját.

Ez azt jelenti, hogy ha egy osztály adattároló, például Book vagy Student osztály, és van néhány mezője az entitással kapcsolatban, akkor csak akkor kell megváltoznia, amikor megváltoztatjuk az adatmodellt.

Az egységes felelősség elvének betartása fontos. Először is, mivel sok különböző csapat dolgozhat ugyanazon a projekten, és különböző okokból ugyanazt az osztályt szerkesztheti, ez inkompatibilis modulokhoz vezethet.

Másodszor megkönnyíti a verziókezelést. Tegyük fel például, hogy van egy állandósági osztályunk, amely kezeli az adatbázis-műveleteket, és változást látunk abban a fájlban a GitHub elkötelezettségében. Az SRP követésével megtudhatjuk, hogy a tárolással vagy az adatbázissal kapcsolatos dolgokkal kapcsolatos.

Az összeolvadási konfliktusok egy másik példa. Akkor jelennek meg, amikor a különböző csapatok ugyanazt a fájlt megváltoztatják. De ha az SRP-t követik, kevesebb konfliktus jelenik meg - a fájloknak egyetlen oka lesz a változtatásra, és a létező konfliktusokat könnyebb megoldani.

Gyakori csapdák és anti-minták

Ebben a részben megvizsgálunk néhány általános hibát, amelyek sértik az egységes felelősség elvét. Majd beszélünk néhány megoldásról.

Példaként megnézzük egy egyszerű könyvesbolt-számlázó program kódját. Kezdjük azzal, hogy meghatározunk egy könyvosztályt, amelyet a számlánkban használhatunk.

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

Ez egy egyszerű könyvosztály, néhány mezővel. Semmi csicsás. Nem teszem priváttá a mezőket, hogy ne kelljen getterekkel és beállítókkal foglalkoznunk, és inkább a logikára tudjunk koncentrálni.

Most hozzuk létre a számlaosztályt, amely tartalmazza a számla létrehozásának és a teljes ár kiszámításának logikáját. Tegyük fel, hogy könyvesboltunk csak könyveket árul, semmi mást.

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

Itt van a számla osztályunk. Néhány mezőt tartalmaz a számlázásról és 3 módszerről:

  • methodTotal módszer, amely kiszámítja a teljes árat,
  • printInvoice módszer, hogy kell nyomtatni a számlát a konzolt,
  • saveToFile módszer, felelős a számla fájlba írásáért.

A következő bekezdés elolvasása előtt adjon magának egy percet, hogy átgondolja, mi a baj ezzel az osztálytervvel.

Ok, akkor mi folyik itt? Osztályunk többféleképpen is megsérti az egységes felelősség elvét.

Az első szabálysértés a printInvoice módszer, amely a nyomtatási logikánkat tartalmazza. Az SRP kijelenti, hogy osztályunknak csak egyetlen oka lehet a változtatásra, és ennek az oknak kell lennie az osztályunk számlájának kiszámításában bekövetkező változásnak.

De ebben az architektúrában, ha meg akarjuk változtatni a nyomtatási formátumot, akkor meg kell változtatnunk az osztályt. Ezért nem szabad, hogy az üzleti logikával kevert nyomtatási logika legyen ugyanabban az osztályban.

Van még egy módszer, amely sérti az osztályunkban az SRP-t: a saveToFile módszer. Rendkívül gyakori hiba a perzisztencia logika és az üzleti logika keverése is.

Ne csak a fájlba történő írásban gondolkodjon: ez lehet mentés egy adatbázisba, API-hívás kezdeményezése vagy más, a kitartással kapcsolatos dolgok.

Tehát hogyan lehet kijavítani ezt a nyomtatási funkciót, kérdezheti.

Új osztályokat hozhatunk létre nyomtatási és kitartási logikánkhoz, így a továbbiakban nem kell módosítanunk a számlaosztályt.

Létrehozunk 2 osztályt, InvoicePrinter és InvoicePersistence, és áthelyezzük a módszereket.

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

Osztályszerkezetünk most betartja az Egységes Felelősség Elvet, és minden osztály felelős alkalmazásunk egy aspektusáért. Nagy!

Nyitott-zárt elv

A nyitott-zárt elv megköveteli, hogy az osztályok nyitva legyenek a kiterjesztéshez, és zárva legyenek a módosítások előtt.

A módosítás egy meglévő osztály kódjának megváltoztatását jelenti, a kiterjesztés pedig új funkciók hozzáadását.

Tehát ezt az elvet akarja mondani: Képesnek kell lennünk új funkciók hozzáadására anélkül, hogy megérintenénk az osztály meglévő kódját. Ennek oka, hogy valahányszor módosítjuk a meglévő kódot, kockáztatjuk a potenciális hibák létrehozását. Ezért lehetőleg kerüljük a tesztelt és megbízható (főleg) gyártási kód megérintését.

De kérdezheted, hogyan fogunk új funkciókat felvenni anélkül, hogy hozzáérnénk az osztályhoz. Általában interfészek és absztrakt osztályok segítségével történik.

Most, hogy áttekintettük az elv alapjait, alkalmazzuk a Számla alkalmazásunkra.

Tegyük fel, hogy a főnökünk odajött hozzánk, és azt mondta, hogy azt akarják, hogy a számlákat elmentse egy adatbázisba, hogy könnyedén megkereshessük őket. Szerintünk rendben van, ez könnyű parasztfőnök, adj csak egy percet!

Létrehozzuk az adatbázist, csatlakozunk hozzá, és hozzáadunk egy mentési módszert az InvoicePersistence osztályunkhoz:

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

Sajnos mi, mint a könyvesbolt lusta fejlesztője, nem úgy terveztük az órákat, hogy azok a későbbiekben könnyen bővíthetőek legyenek. Tehát ennek a szolgáltatásnak a hozzáadásához módosítottuk az InvoicePersistence osztályt.

Ha az osztálytervünk engedelmeskedik a nyitott-zárt elvnek, akkor nem kell változtatnunk ezen az osztályon.

Tehát, mint a könyvesbolt lusta, de okos fejlesztője, látjuk a tervezési problémát, és úgy döntünk, hogy az alapelvnek való megfelelés érdekében átalakítjuk a kódot.

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

Megváltoztatjuk az InvoicePersistence típusát Interface-re, és hozzáadunk egy mentési módszert. Minden perzisztencia osztály megvalósítja ezt a mentési módszert.

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

Osztályszerkezetünk tehát így néz ki:

Most kitartási logikánk könnyen kibővíthető. Ha a főnökünk arra kér minket, hogy adjunk hozzá egy másik adatbázist, és 2 különböző típusú adatbázissal rendelkezzünk, például MySQL és MongoDB, akkor ezt könnyen megtehetjük.

Gondolhatja, hogy egyszerűen létrehozhatunk több osztályt interfész nélkül, és mindegyikhez hozzáadhatunk egy mentési módszert.

De tegyük fel, hogy kibővítettük az alkalmazásunkat, és több olyan perzisztencia osztályunk van, mint az InvoicePersistence , a BookPersistence, és létrehozunk egy PersistenceManager osztályt, amely az összes perzisztencia osztályt kezeli:

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

Bármely osztályt, amely az InvoicePersistence felületet megvalósítja, átadhatjuk ennek az osztálynak a polimorfizmus segítségével. Az interfészek ezt a rugalmasságot nyújtják.

Liskov helyettesítés elve

A Liskov Substitution Principle kimondja, hogy az alosztályoknak helyettesíteniük kell az alaposztályaikat.

Ez azt jelenti, hogy mivel a B osztály az A osztály egyik alosztálya, képesnek kell lennünk egy B osztályú objektum átadására bármely olyan módszerre, amely egy A osztályú objektumra számít, és a módszer nem adhat furcsa kimenetet ebben az esetben.

Ez az elvárt viselkedés, mert az öröklés használatakor feltételezzük, hogy a gyermekosztály örököl mindent, ami a szuperosztálynak van. A gyermekosztály kiterjeszti a viselkedést, de soha nem szűkíti le.

Ezért, ha egy osztály nem engedelmeskedik ennek az elvnek, néhány csúnya hibához vezet, amelyeket nehéz felismerni.

Liskov elve könnyen érthető, de kódban nehezen észlelhető. Tehát nézzünk meg egy példát.

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

Van egy egyszerű Rectangle osztályunk és egy getArea függvényünk, amely visszaadja a téglalap területét.

Most úgy döntünk, hogy létrehozunk egy másik osztályt a Négyzetek számára. Mint lehet, a négyzet csak egy speciális téglalaptípus, ahol a szélesség megegyezik a magassággal.

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

Négyzet osztályunk kiterjeszti a téglalap osztályt. A konstruktorban ugyanarra az értékre állítottuk a magasságot és a szélességet, de nem szeretnénk, ha bármely kliens (valaki, aki az osztályunkat használja a kódjában) megváltoztassa a magasságot vagy a súlyt úgy, hogy az megsérthesse a négyzet tulajdonságát.

Ezért felülírjuk a beállítókat, hogy mindkét tulajdonságot beállítsuk, amikor az egyiket megváltoztatjuk. De ezzel éppen megsértettük a Liskov-helyettesítés elvét.

Hozzunk létre egy fő osztályt a getArea függvény tesztjeinek elvégzéséhez .

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.

Most elválasztottuk a parkolót. Ezzel az új modellel akár tovább is léphetünk, és feloszthatjuk a PaidParkingLot-ot a különféle fizetési módok támogatása érdekében.

Most a modellünk sokkal rugalmasabb, bővíthetőbb, és az ügyfeleknek nem kell semmilyen irreleváns logikát megvalósítaniuk, mert a parkoló felületén csak a parkolással kapcsolatos funkciókat biztosítjuk.

Függőségi inverzió elve

A Dependency Inversion elv kijelenti, hogy osztályainknak interfészektől vagy absztrakt osztályoktól kell függeniük, nem pedig konkrét osztályoktól és függvényektől.

Bob bácsi cikkében (2000) ezt az elvet a következőképpen foglalja össze:

"Ha az OCP kimondja az OO architektúra célját, akkor a DIP megadja az elsődleges mechanizmust".

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.