Надійний посібник із твердих принципів

1. Вступ

У цьому підручнику ми обговоримо тверді принципи об’єктно-орієнтованого проектування.

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

2. Причина твердих принципів

Вперше принципи SOLID були осмислені Робертом К. Мартіном у його роботі 2000 року « Принципи дизайну та шаблони дизайну». Пізніше ці концепції були побудовані Майклом Пером, який познайомив нас із твердою абревіатурою. І за останні 20 років ці 5 принципів революціонізували світ об’єктно-орієнтованого програмування, змінивши спосіб написання програмного забезпечення.

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

Наступні 5 концепцій складають наші тверді принципи:

  1. S Ingle Відповідальність
  2. O ручка / Закрита
  3. Л ісков Заміна
  4. Я сегрегація інтерфейсу
  5. D ependency Inversion

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

3. Єдина відповідальність

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

Як цей принцип допомагає нам створювати більш якісне програмне забезпечення? Давайте розглянемо кілька його переваг:

  1. Тестування - Клас з однією відповідальністю матиме набагато менше тестових випадків
  2. Нижче зчеплення - менше функціональних можливостей одного класу матиме менше залежностей
  3. Організація - Менші, добре організовані класи легше шукати, ніж монолітні

Візьмемо, наприклад, клас, який представляє просту книгу:

public class Book { private String name; private String author; private String text; //constructor, getters and setters }

У цьому коді ми зберігаємо ім’я, автора та текст, пов’язані з екземпляром Книги .

Давайте тепер додамо кілька методів для запиту тексту:

public class Book { private String name; private String author; private String text; //constructor, getters and setters // methods that directly relate to the book properties public String replaceWordInText(String word){ return text.replaceAll(word, text); } public boolean isWordInText(String word){ return text.contains(word); } }

Зараз наш клас « Книга» працює добре, і ми можемо зберігати стільки книжок, скільки нам подобається у нашому додатку. Але яка користь зберігання інформації, якщо ми не можемо вивести текст на нашу консоль і прочитати її?

Давайте кинемо обережність і додамо метод друку:

public class Book { //... void printTextToConsole(){ // our code for formatting and printing the text } }

Однак цей кодекс порушує принцип єдиної відповідальності, який ми окреслили раніше. Щоб виправити наш безлад, нам слід запровадити окремий клас, який стосується лише друку наших текстів:

public class BookPrinter { // methods for outputting text void printTextToConsole(String text){ //our code for formatting and printing the text } void printTextToAnotherMedium(String text){ // code for writing to any other location.. } }

Приголомшливо Ми не тільки розробили клас, який звільняє Книгу від її друкарських обов'язків, але ми також можемо використовувати наш клас BookPrinter для надсилання нашого тексту на інші носії інформації.

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

4. Відкритий для розширення, Закритий для модифікації

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

Звичайно, єдиним винятком із правила є виправлення помилок у існуючому коді.

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

Він повноцінний і навіть має регулятор гучності:

public class Guitar { private String make; private String model; private int volume; //Constructors, getters & setters }

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

На даний момент може бути спокусливим просто відкрити клас гітари та додати шаблон полум’я - але хто знає, які помилки можуть виникнути в нашому додатку.

Натомість, давайте дотримуватися принципу відкрито-закрито і просто розширити наш клас гітари :

public class SuperCoolGuitarWithFlames extends Guitar { private String flameColor; //constructor, getters + setters }

Розширюючи клас гітари, ми можемо бути впевнені, що наша існуюча програма не постраждає.

5. Заміна Ліскова

Далі в нашому списку йде заміна Ліскова, яка є, мабуть, найскладнішою з 5 принципів. Простіше кажучи, якщо клас A є підтипом класу B , тоді ми повинні мати можливість замінити B на A, не порушуючи поведінку нашої програми.

Давайте просто перейдемо прямо до коду, щоб допомогти обернути наші голови навколо цієї концепції:

public interface Car { void turnOnEngine(); void accelerate(); }

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

Давайте реалізуємо наш інтерфейс і надамо деякий код для методів:

public class MotorCar implements Car { private Engine engine; //Constructors, getters + setters public void turnOnEngine() { //turn on the engine! engine.on(); } public void accelerate() { //move forward! engine.powerOn(1000); } }

Як описує наш код, у нас є двигун, який ми можемо увімкнути, і ми можемо збільшити потужність. Але почекайте, 2019 рік, і Ілон Маск був зайнятою людиною.

We are now living in the era of electric cars:

public class ElectricCar implements Car { public void turnOnEngine() { throw new AssertionError("I don't have an engine!"); } public void accelerate() { //this acceleration is crazy! } }

By throwing a car without an engine into the mix, we are inherently changing the behavior of our program. This is a blatant violation of Liskov substitution and is a bit harder to fix than our previous 2 principles.

One possible solution would be to rework our model into interfaces that take into account the engine-less state of our Car.

6. Interface Segregation

The ‘I ‘ in SOLID stands for interface segregation, and it simply means that larger interfaces should be split into smaller ones. By doing so, we can ensure that implementing classes only need to be concerned about the methods that are of interest to them.

For this example, we're going to try our hands as zookeepers. And more specifically, we'll be working in the bear enclosure.

Let's start with an interface that outlines our roles as a bear keeper:

public interface BearKeeper { void washTheBear(); void feedTheBear(); void petTheBear(); }

As avid zookeepers, we're more than happy to wash and feed our beloved bears. However, we're all too aware of the dangers of petting them. Unfortunately, our interface is rather large, and we have no choice than to implement the code to pet the bear.

Let's fix this by splitting our large interface into 3 separate ones:

public interface BearCleaner { void washTheBear(); } public interface BearFeeder { void feedTheBear(); } public interface BearPetter { void petTheBear(); }

Now, thanks to interface segregation, we're free to implement only the methods that matter to us:

public class BearCarer implements BearCleaner, BearFeeder { public void washTheBear() { //I think we missed a spot... } public void feedTheBear() { //Tuna Tuesdays... } }

And finally, we can leave the dangerous stuff to the crazy people:

public class CrazyPerson implements BearPetter { public void petTheBear() { //Good luck with that! } }

Going further, we could even split our BookPrinter class from our example earlier to use interface segregation in the same way. By implementing a Printer interface with a single print method, we could instantiate separate ConsoleBookPrinter and OtherMediaBookPrinter classes.

7. Dependency Inversion

The principle of Dependency Inversion refers to the decoupling of software modules. This way, instead of high-level modules depending on low-level modules, both will depend on abstractions.

To demonstrate this, let's go old-school and bring to life a Windows 98 computer with code:

public class Windows98Machine {}

But what good is a computer without a monitor and keyboard? Let's add one of each to our constructor so that every Windows98Computer we instantiate comes pre-packed with a Monitor and a StandardKeyboard:

public class Windows98Machine { private final StandardKeyboard keyboard; private final Monitor monitor; public Windows98Machine() { monitor = new Monitor(); keyboard = new StandardKeyboard(); } }

This code will work, and we'll be able to use the StandardKeyboard and Monitor freely within our Windows98Computer class. Problem solved? Not quite. By declaring the StandardKeyboard and Monitor with the new keyword, we've tightly coupled these 3 classes together.

Not only does this make our Windows98Computer hard to test, but we've also lost the ability to switch out our StandardKeyboard class with a different one should the need arise. And we're stuck with our Monitor class, too.

Let's decouple our machine from the StandardKeyboard by adding a more general Keyboard interface and using this in our class:

public interface Keyboard { }
public class Windows98Machine{ private final Keyboard keyboard; private final Monitor monitor; public Windows98Machine(Keyboard keyboard, Monitor monitor) { this.keyboard = keyboard; this.monitor = monitor; } }

Here, we're using the dependency injection pattern here to facilitate adding the Keyboard dependency into the Windows98Machine class.

Let's also modify our StandardKeyboard class to implement the Keyboard interface so that it's suitable for injecting into the Windows98Machine class:

public class StandardKeyboard implements Keyboard { }

Now our classes are decoupled and communicate through the Keyboard abstraction. If we want, we can easily switch out the type of keyboard in our machine with a different implementation of the interface. We can follow the same principle for the Monitor class.

Excellent! We've decoupled the dependencies and are free to test our Windows98Machine with whichever testing framework we choose.

8. Conclusion

In this tutorial, we've taken a deep dive into the SOLID principles of object-oriented design.

Ми почали з короткого опису історії SOLID та причин, за якими існують ці принципи.

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

Як завжди, код доступний на GitHub.