Перейти к основному содержимому

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

· 9 мин. чтения

1. Обзор

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

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

2. Причина для принципов SOLID

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

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

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

  1. Единая ответственность
  2. Открытый /закрытый
  3. Лесков Замена
  4. Разделение интерфейса
  5. Инверсия зависимости

Хотя эти концепции могут показаться сложными, их можно легко понять с помощью нескольких простых примеров кода. В следующих разделах мы подробно рассмотрим эти принципы, проиллюстрировав каждый из них кратким примером Java.

3. Единственная ответственность

Начнем с принципа единой ответственности. Как и следовало ожидать, этот принцип гласит, что у класса должна быть только одна обязанность. Кроме того, у него должна быть только одна причина для изменения.

Как этот принцип помогает нам создавать лучшее программное обеспечение? Давайте рассмотрим несколько его преимуществ:

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

Например, давайте посмотрим на класс для представления простой книги:

public class Book {

private String name;
private String author;
private String text;

//constructor, getters and setters
}

В этом коде мы сохраняем имя, автора и текст, связанные с экземпляром Book .

Теперь добавим пару методов для запроса текста:

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. Открыто для расширения, закрыто для модификации

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

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

Давайте рассмотрим концепцию на примере быстрого кода. Представьте, что в рамках нового проекта мы реализовали класс 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. Замена Лискова

Далее в нашем списке идет замена Лискова , возможно, самый сложный из пяти принципов. Проще говоря, если класс A является подтипом класса B , мы должны иметь возможность заменить B на A , не нарушая поведения нашей программы.

Давайте сразу перейдем к коду, который поможет нам понять эту концепцию:

public interface Car {

void turnOnEngine();
void accelerate();
}

Выше мы определяем простой интерфейс Car с парой методов, которые должны выполнять все автомобили: включение двигателя и ускорение вперед.

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

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

Как описывает наш код, у нас есть двигатель, который мы можем включить, и мы можем увеличить мощность.

Но подождите — мы сейчас живем в эпоху электромобилей:

public class ElectricCar implements Car {

public void turnOnEngine() {
throw new AssertionError("I don't have an engine!");
}

public void accelerate() {
//this acceleration is crazy!
}
}

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

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

6. Разделение интерфейса

I в SOLID означает разделение интерфейсов, и это просто означает, что большие интерфейсы должны быть разделены на более мелкие. Поступая так, мы можем гарантировать, что реализующие классы должны заботиться только о тех методах, которые их интересуют.

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

Давайте начнем с интерфейса, который описывает наши роли в качестве медвежьего хранителя:

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

Как заядлые смотрители зоопарка, мы более чем счастливы мыть и кормить наших любимых медведей. Но мы все слишком хорошо знаем об опасностях их ласки. К сожалению, наш интерфейс довольно большой, и у нас нет другого выбора, кроме как реализовать код, чтобы погладить медведя.

Давайте исправим это, разделив наш большой интерфейс на три отдельных :

public interface BearCleaner {
void washTheBear();
}

public interface BearFeeder {
void feedTheBear();
}

public interface BearPetter {
void petTheBear();
}

Теперь, благодаря разделению интерфейсов, мы можем реализовать только те методы, которые нам важны:

public class BearCarer implements BearCleaner, BearFeeder {

public void washTheBear() {
//I think we missed a spot...
}

public void feedTheBear() {
//Tuna Tuesdays...
}
}

И, наконец, мы можем оставить опасные вещи безрассудным людям:

public class CrazyPerson implements BearPetter {

public void petTheBear() {
//Good luck with that!
}
}

Идя дальше, мы могли бы даже отделить наш класс BookPrinter от нашего предыдущего примера, чтобы таким же образом использовать разделение интерфейса. Реализуя интерфейс Printer с одним методом печати , мы могли бы создавать экземпляры отдельных классов ConsoleBookPrinter и OtherMediaBookPrinter .

7. Инверсия зависимости

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

Чтобы продемонстрировать это, давайте вернемся к старой школе и воплотим в жизнь компьютер с Windows 98 с кодом:

public class Windows98Machine {}

Но что хорошего в компьютере без монитора и клавиатуры? Давайте добавим по одному из них в наш конструктор, чтобы каждый экземпляр Windows98Computer , который мы создаем, был предварительно упакован с Monitor и StandardKeyboard :

public class Windows98Machine {

private final StandardKeyboard keyboard;
private final Monitor monitor;

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

}

Этот код будет работать, и мы сможем свободно использовать StandardKeyboard и Monitor в нашем классе Windows98Computer .

Задача решена? Не совсем. Объявив StandardKeyboard и Monitor с новым ключевым словом, мы тесно связали эти три класса вместе.

Это не только затрудняет тестирование нашего Windows98Computer , но и лишает нас возможности переключать наш класс StandardKeyboard на другой, если в этом возникнет необходимость. И мы тоже застряли с нашим классом Monitor .

Давайте отделим нашу машину от StandardKeyboard , добавив более общий интерфейс клавиатуры и используя его в нашем классе:

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

Здесь мы используем шаблон внедрения зависимостей, чтобы облегчить добавление зависимости Keyboard в класс Windows98Machine .

Давайте также изменим наш класс StandardKeyboard , чтобы реализовать интерфейс Keyboard , чтобы он подходил для внедрения в класс Windows98Machine :

public class StandardKeyboard implements Keyboard { }

Теперь наши классы отделены и взаимодействуют через абстракцию клавиатуры . Если мы хотим, мы можем легко переключить тип клавиатуры в нашей машине с другой реализацией интерфейса. Мы можем следовать тому же принципу для класса Monitor .

Превосходно! Мы отделили зависимости и можем свободно тестировать нашу Windows98Machine с любой выбранной нами средой тестирования.

8. Заключение

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

Мы начали с краткой истории SOLID и причин существования этих принципов.

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

Как всегда, код доступен на GitHub .