Полное руководство по принципам SOLID

1. Введение

В этом руководстве мы обсудим принципы SOLID объектно-ориентированного дизайна.

Во-первых, мы начнем с изучения причин, по которым они возникли, и почему мы должны учитывать их при разработке программного обеспечения. Затем мы опишем каждый принцип вместе с некоторыми примерами кода, чтобы подчеркнуть суть.

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

Принципы SOLID были впервые концептуализированы Робертом К. Мартином в его статье 2000 года « Принципы проектирования и шаблоны проектирования». Позже эти концепции были развиты Майклом Фезерсом, который познакомил нас с аббревиатурой SOLID. И за последние 20 лет эти 5 принципов произвели революцию в мире объектно-ориентированного программирования, изменив способ написания программного обеспечения.

Итак, что такое SOLID и как он помогает нам писать лучший код? Проще говоря, принципы дизайна Мартина и Фезерса побуждают нас создавать более удобное, понятное и гибкое программное обеспечение . Следовательно, по мере увеличения размера наших приложений мы можем уменьшить их сложность и избавить себя от многих головных болей в будущем!

Следующие 5 концепций составляют наши твердые принципы:

  1. S Ingle Ответственность
  2. O перо / закрыто
  3. L Иськов Замена
  4. Я nterface Сегрегация
  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); } }

Теперь наш класс Book работает хорошо, и мы можем хранить сколько угодно книг в нашем приложении. Но какой толк в хранении информации, если мы не можем вывести текст на консоль и прочитать его?

Давайте бросим осторожность и добавим метод печати:

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

Потрясающе. Мы не только разработали класс, который освобождает Book от обязанностей печати, но мы также можем использовать наш класс BookPrinter для отправки текста на другие носители.

Будь то электронная почта, ведение журнала или что-то еще, у нас есть отдельный класс, посвященный этой проблеме.

4. Открыто для продления, закрыто для изменения

Теперь время для «О» - более формально известного как принцип открыт-закрыт . Проще говоря, классы должны быть открыты для расширения, но закрыты для модификации. Поступая таким образом, мы не позволяем себе изменять существующий код и вызывать новые потенциальные ошибки в других приложениях.

Конечно, единственным исключением из правил является исправление ошибок в существующем коде.

Давайте подробнее рассмотрим эту концепцию на примере быстрого кода. Представьте, что в рамках нового проекта мы реализовали класс Guitar .

Он полноценный и даже имеет ручку регулировки громкости:

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

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

На этом этапе может возникнуть соблазн просто открыть класс Guitar и добавить образец пламени - но кто знает, какие ошибки могут возникнуть в нашем приложении.

Вместо этого давайте придерживаться принципа открытого-закрытого и просто расширим наш класс Guitar :

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

Расширяя класс Guitar, мы можем быть уверены, что наше существующее приложение не пострадает.

5. Лисков Замена

Next up on our list is Liskov substitution, which is arguably the most complex of the 5 principles. Simply put, if class A is a subtype of class B, then we should be able to replace B with A without disrupting the behavior of our program.

Let's just jump straight to the code to help wrap our heads around this concept:

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

Above, we define a simple Car interface with a couple of methods that all cars should be able to fulfill – turning on the engine, and accelerating forward.

Let's implement our interface and provide some code for the methods:

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

As our code describes, we have an engine that we can turn on, and we can increase the power. But wait, its 2019, and Elon Musk has been a busy man.

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.