ТВЕРДІ Принципи об’єктно-орієнтованого програмування пояснені простою англійською мовою

SOLID Principles - це п’ять принципів об’єктно-орієнтованого дизайну класів. Вони являють собою набір правил та найкращих практик, яких слід дотримуватися при розробці структури класу.

Ці п’ять принципів допомагають нам зрозуміти необхідність певних моделей дизайну та архітектури програмного забезпечення загалом. Тому я вважаю, що це тема, яку повинен вивчити кожен розробник.

Ця стаття навчить вас усьому, що вам потрібно знати, щоб застосовувати принципи SOLID до своїх проектів.

Ми почнемо з того, що заглянемо в історію цього терміна. Тоді ми збираємося заглибитися в дрібні деталі - чому та як кожен із принципів - створивши дизайн класу та вдосконалюючи його поетапно.

Тож візьміть чашку кави або чаю і давайте стрибаємо прямо!

Передумови

Вперше принципи SOLID були введені відомим комп’ютерним вченим Робертом Дж. Мартіном (він же дядько Боб) у своїй роботі в 2000 році. Але згодом SOLID-абревіатура була введена Майклом Пером.

Дядько Боб також є автором бестселерів " Чистий код" та " Чиста архітектура" , а також є одним із учасників "Спритного альянсу".

Тому не дивно, що всі ці концепції чистого кодування, об’єктно-орієнтованої архітектури та шаблонів дизайну якимось чином пов’язані та доповнюють одна одну.

Всі вони служать одній меті:

"Створити зрозумілий, читабельний і перевіряється код, над яким багато розробників можуть працювати спільно."

Давайте розглянемо кожен принцип по одному. Після абревіатури SOLID вони:

  • S Ingle відповідальності Принцип
  • O пен Закритий Принцип
  • Принцип заміщення Л ісков
  • 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 методи:

  • calculateTotal метод, який обчислює загальну ціну,
  • метод 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 } }

Тепер наша структура класів дотримується принципу єдиної відповідальності, і кожен клас відповідає за один аспект нашого застосування. Чудово!

Відкрито-закритий принцип

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

Модифікація означає зміну коду існуючого класу, а розширення означає додавання нових функціональних можливостей.

Отже, що хоче сказати цей принцип: ми повинні мати можливість додавати нові функції, не торкаючись існуючого коду класу. Це пов’язано з тим, що всякий раз, коли ми модифікуємо існуючий код, ми ризикуємо створити потенційні помилки. Тому нам слід уникати торкання перевіреного та надійного (переважно) виробничого коду, якщо це можливо.

Але як ми будемо додавати нові функції, не торкаючись класу, можете запитати ви. Зазвичай це робиться за допомогою інтерфейсів та абстрактних класів.

Тепер, коли ми розкрили основи принципу, давайте застосуємо його до нашого додатка Рахунок.

Скажімо, наш бос прийшов до нас і сказав, що вони хочуть, щоб рахунки зберігались у базі даних, щоб ми могли їх легко шукати. Ми гадаємо, добре, це неважко, бос, просто дайте мені секунду!

Ми створюємо базу даних, підключаємось до неї та додаємо метод збереження до нашого класу 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.