Твърдите принципи на обектно-ориентираното програмиране, обяснени на обикновен английски

SOLID Principles са пет принципа на обектно-ориентиран клас дизайн. Те са набор от правила и най-добри практики, които трябва да се следват при проектирането на структура на класа.

Тези пет принципа ни помагат да разберем необходимостта от определени дизайнерски модели и софтуерна архитектура като цяло. Така че вярвам, че това е тема, която всеки разработчик трябва да научи.

Тази статия ще ви научи на всичко, което трябва да знаете, за да приложите SOLID принципите към вашите проекти.

Ще започнем, като разгледаме историята на този термин. След това ще влезем в подробностите - защо и как е на всеки принцип - като създадем дизайн на класа и го подобряваме стъпка по стъпка.

Така че вземете чаша кафе или чай и нека скочим направо!

Заден план

Принципите на SOLID са представени за първи път от известния компютърен учен Робърт Дж. Мартин (известен още като чичо Боб) в неговия доклад през 2000 г. Но абревиатурата SOLID е въведена по-късно от Майкъл Федърс.

Чичо Боб е автор и на бестселърите „ Чист код и чиста архитектура “ и е един от участниците в „Agile Alliance“.

Ето защо не е изненадващо, че всички тези концепции за чисто кодиране, обектно-ориентирана архитектура и дизайнерски модели са някак свързани и взаимно допълващи се.

Всички те служат на една и съща цел:

„За създаване на разбираем, четим и проверяем код, върху който много разработчици могат да работят съвместно.“

Нека разгледаме всеки принцип един по един. След съкращението SOLID те са:

  • Принципът за отговорност на S ingle
  • Принципът на затворена O писалка
  • В L Заместването Принцип iskov
  • В I nterface Сегрегацията Принцип
  • Най- D ependency Инверсия Принцип

Принципът на единната отговорност

Принципът за единна отговорност гласи, че класът трябва да прави едно нещо и следователно трябва да има само една причина да се промени .

За да заявим този принцип по-технически: Само една потенциална промяна (логика на базата данни, логика на регистриране и т.н.) в спецификацията на софтуера трябва да може да повлияе на спецификацията на класа.

Това означава, че ако клас е контейнер за данни, като клас Book или клас Student и има някои полета по отношение на този обект, той трябва да се промени само когато променим модела на данни.

Спазването на принципа на единна отговорност е важно. На първо място, тъй като много различни екипи могат да работят по един и същ проект и да редактират един и същи клас по различни причини, това може да доведе до несъвместими модули.

Второ, улеснява контрола на версиите. Например, да кажем, че имаме клас на устойчивост, който обработва операции с база данни и виждаме промяна в този файл в GitHub фиксира. Следвайки SRP, ще знаем, че той е свързан със съхранение или неща, свързани с база данни.

Конфликтите за сливане са друг пример. Те се появяват, когато различни екипи променят един и същ файл. Но ако се спазва SRP, ще се появят по-малко конфликти - файловете ще имат единствена причина за промяна и съществуващите конфликти ще бъдат по-лесни за разрешаване.

Често срещани клопки и анти-модели

В този раздел ще разгледаме някои често срещани грешки, които нарушават принципа на единна отговорност. Тогава ще говорим за някои начини да ги поправим.

Като пример ще разгледаме кода за проста програма за фактури в книжарница. Нека започнем с дефиниране на клас книга, който да се използва във нашата фактура.

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

Това е прост клас на книга с някои полета. Нищо изискано. Не правя полета частни, за да не се налага да се занимаваме с гетери и сетери и вместо това да се съсредоточим върху логиката.

Сега нека създадем клас фактура, който ще съдържа логиката за създаване на фактура и изчисляване на общата цена. Засега приемете, че нашата книжарница продава само книги и нищо друго.

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

Ето нашия клас фактури. Той също така съдържа някои полета за фактуриране и 3 метода:

  • methodTotal метод, който изчислява общата цена,
  • printInvoice метод, който трябва да отпечата фактурата на конзолата и
  • метод saveToFile , отговорен за записването на фактурата във файл.

Трябва да си дадете секунда, за да помислите какво не е наред с този дизайн на класа, преди да прочетете следващия параграф.

Добре, какво става тук? Нашият клас нарушава принципа на единна отговорност по множество начини.

Първото нарушение е методът printInvoice , който съдържа нашата логика за печат. SRP заявява, че нашият клас трябва да има само една причина да се промени и тази причина трябва да бъде промяна в изчислението на фактурата за нашия клас.

Но в тази архитектура, ако искахме да променим формата за печат, ще трябва да променим класа. Ето защо не бива да имаме логика за печат, смесена с бизнес логика в същия клас.

Има още един метод, който нарушава SRP в нашия клас: методът saveToFile . Също така е изключително често срещана грешка да се смесва логиката на постоянство с бизнес логиката.

Не мислете само по отношение на писането във файл - това може да бъде записване в база данни, извършване на API извикване или други неща, свързани с постоянството.

И така, как можем да поправим тази функция за печат, можете да попитате.

Можем да създадем нови класове за нашата логика за печат и постоянство, така че вече няма да се налага да модифицираме класа на фактурата за тези цели.

Създаваме 2 класа InvoicePrinter и InvoicePersistence и преместваме методите.

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

Сега нашата класова структура се подчинява на Принципа на единна отговорност и всеки клас отговаря за един аспект от нашето приложение. Страхотен!

Отворен затворен принцип

Принципът Open-Closed изисква класовете да бъдат отворени за разширение и затворени за модификация.

Модификацията означава промяна на кода на съществуващ клас, а разширението означава добавяне на нова функционалност.

И така, това, което този принцип иска да каже, е: Трябва да можем да добавим нова функционалност, без да докосваме съществуващия код за класа. Това е така, защото винаги, когато модифицираме съществуващия код, поемаме риска да създадем потенциални грешки. Затова трябва да избягваме да докосваме тествания и надежден (най-вече) производствен код, ако е възможно.

Но как ще добавим нова функционалност, без да докосваме класа, може да попитате. Обикновено се прави с помощта на интерфейси и абстрактни класове.

След като разгледахме основите на принципа, нека го приложим към нашето приложение за фактури.

Да приемем, че шефът ни дойде при нас и каза, че искат фактурите да се запазват в база данни, за да можем лесно да ги търсим. Смятаме, че е добре, това е лесно, дрън шефе, само ми дайте секунда!

Създаваме базата данни, свързваме се с нея и добавяме метод за запис към нашия клас 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 } }

За съжаление ние, като мързеливият разработчик на книжарницата, не проектирахме класовете да бъдат лесно разширяеми в бъдеще. Така че, за да добавим тази функция, сме модифицирали класа InvoicePersistence .

Ако дизайнът на класа ни се подчиняваше на принципа на отворено затворено, нямаше да се налага да променяме този клас.

И така, като мързелив, но умен разработчик на книжарницата, ние виждаме проблема с дизайна и решаваме да рефакторираме кода, за да се подчиним на принципа.

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

Променяме типа InvoicePersistence на Interface и добавяме метод за запис. Всеки клас на постоянство ще приложи този метод за запазване.

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

Така че нашата структура на класа сега изглежда така:

Сега нашата логика на постоянство е лесно разширима. Ако шефът ни поиска да добавим друга база данни и имаме 2 различни типа бази данни като MySQL и MongoDB, можем лесно да направим това.

Може би си мислите, че бихме могли просто да създадем множество класове без интерфейс и да добавим метод за запис към всички тях.

Но да кажем, че ние разширяваме нашето приложение и имаме множество класове за устойчивост като InvoicePersistence , BookPersistence и създаваме клас PersistenceManager, който управлява всички класове за устойчивост:

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

Вече можем да предадем на този клас всеки клас, който реализира интерфейса InvoicePersistence с помощта на полиморфизъм. Това е гъвкавостта, която интерфейсите предоставят.

Принцип на заместване на Лисков

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.

Сега сме отделили паркинга. С този нов модел можем дори да стигнем по-далеч и да разделим PaidParkingLot, за да поддържаме различни видове плащания.

Сега нашият модел е много по-гъвкав, разширяем и клиентите не трябва да прилагат никаква ирелевантна логика, защото ние предлагаме само функционалност, свързана с паркирането в интерфейса на паркинга.

Принцип на инверсия на зависимостта

Принципът на инверсия на зависимостите гласи, че нашите класове трябва да зависят от интерфейси или абстрактни класове, вместо от конкретни класове и функции.

В своята статия (2000) чичо Боб обобщава този принцип, както следва:

"Ако OCP посочва целта на архитектурата на ОО, DIP посочва основния механизъм".

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.