SOLID на котиках: коротко и по делу
Каждый разработчик знает, каково это — увидеть код, который страшно трогать. В нём всё ломается, стоит добавить пару строк. Чтобы такого не было, мир придумал SOLID — набор из пяти принципов, которые делают ваш код понятным, надёжным и лёгким в поддержке.
В этой статье рассмотрим, как внедрять эти принципы с умом, и да, будет немного котиков — куда без них.
Что такое SOLID и зачем это нужно?
SOLID — это пять принципов проектирования, которые помогают писать читаемый, сопровождаемый и расширяемый код.
S (Single Responsibility): один класс — одна ответственность.
O (Open/Closed): открыт для расширения, закрыт для изменения.
L (Liskov Substitution): дочерние классы заменяют родительские без сюрпризов.
I (Interface Segregation): узкие интерфейсы лучше широких.
D (Dependency Inversion): зависимость от абстракций, а не реализаций.
Зачем это нужно?
Изменение одной части приложения ломает всё (нарушение S).
Добавление новой фичи требует переписывать старый код (нарушение O).
Наследники ведут себя непредсказуемо (нарушение L).
Перегруженные интерфейсы заставляют писать лишний код (нарушение I).
Зависимость от конкретных реализаций делает код негибким (нарушение D).
SOLID помогает строить архитектуру, которая выдерживает изменения и масштабируется без лилшних заморочек.
Теперь разберем каждый принцип отдельно.
S: принцип единственной ответственности
Каждый класс должен отвечать за одну-единственную задачу. Т.е он должен быть о чём-то одном.
Вы наверняка видели такой код:
public class Cat {
private String name;
public Cat(String name) {
this.name = name;
}
public void eat() {
System.out.println(name + " ест.");
}
public void sleep() {
System.out.println(name + " спит.");
}
public void cleanLitterBox() {
System.out.println("Убираем лоток для " + name + ".");
}
}
На первый взгляд — всё нормально. Но проблема станет явной, как только понадобится переиспользовать логику cleanLitterBox
в классе, не связанном с котами. Например, вы захотите сделать уборку универсальной для всех домашних животных. Тут начнётся переписывание кода.
Разделим всё на отдельные классы:
public class Cat {
private final String name;
public Cat(String name) {
this.name = name;
}
public void eat() {
System.out.println(name + " ест.");
}
public void sleep() {
System.out.println(name + " спит.");
}
}
public class LitterBoxService {
public void cleanLitterBox(String animalName) {
System.out.println("Убираем лоток для " + animalName + ".");
}
}
Теперь Cat
занимается только своим поведением, а уборка делегирована сервису. Хотите добавить собачку? Это получится легко.
O: принцип открытости/закрытости
Код должен быть открыт для расширения, но закрыт для изменения. То есть можно добавлять новый функционал без изменения существующего кода.
Допустим, нужно добавить новых котов и пишем код вот так:
public class CatService {
public void makeSound(String catType) {
if (catType.equals("домашний")) {
System.out.println("Мяу!");
} else if (catType.equals("дикий")) {
System.out.println("Ррр!");
} else {
throw new IllegalArgumentException("Неизвестный тип кота.");
}
}
}
Каждый новый тип кота — новая ветка в if-else
. Этот код плохо тестируется, и его сложно поддерживать.
Используем интерфейсы и создаём классы для каждого типа кота:
public interface Cat {
void makeSound();
}
public class DomesticCat implements Cat {
@Override
public void makeSound() {
System.out.println("Мяу!");
}
}
public class WildCat implements Cat {
@Override
public void makeSound() {
System.out.println("Ррр!");
}
}
А теперь сервис:
public class CatService {
public void playWithCat(Cat cat) {
cat.makeSound();
}
}
При добавлении нового кота мы просто пишем новый класс. Сервис остаётся неизменным. Это и есть принцип открытости/закрытости.
L: принцип подстановки Барбары Лисков
Дочерний класс должен заменять родительский без изменений в поведении программы. Если замена ломает систему — вы нарушили принцип.
Пример:
public class Cat {
public void eat() {
System.out.println("Кот ест.");
}
}
public class ToyCat extends Cat {
@Override
public void eat() {
throw new UnsupportedOperationException("Игрушечный кот не ест.");
}
}
Если метод работает с Cat
, то передача ToyCat
вызовет ошибку.
Используем интерфейс для общего поведения:
public interface Cat {
void makeSound();
}
public class RealCat implements Cat {
@Override
public void makeSound() {
System.out.println("Мяу!");
}
}
public class ToyCat implements Cat {
@Override
public void makeSound() {
System.out.println("Пи-пи!");
}
}
Теперь ToyCat
никогда не вызовет UnsupportedOperationException
.
I: принцип разделения интерфейсов
Большие интерфейсы — зло. Лучше несколько маленьких, чем один огромный.
Раздутый интерфейс:
public interface Cat {
void eat();
void sleep();
void climbTree();
}
Не все коты лазают по деревьям. Если HouseCat
имплементирует этот интерфейс, ему придётся писать пустую реализацию climbTree
.
Разделим интерфейсы по ролям:
public interface BasicCat {
void eat();
void sleep();
}
public interface TreeClimbingCat {
void climbTree();
}
Теперь классы реализуют только то, что им нужно:
public class HouseCat implements BasicCat {
@Override
public void eat() {
System.out.println("Мяу, я ем.");
}
@Override
public void sleep() {
System.out.println("Я дремлю.");
}
}
public class ForestCat implements BasicCat, TreeClimbingCat {
@Override
public void eat() {
System.out.println("Я ем мышей.");
}
@Override
public void sleep() {
System.out.println("Дремлю на дереве.");
}
@Override
public void climbTree() {
System.out.println("Лазаю по деревьям.");
}
}
D: принцип инверсии зависимостей
Высокоуровневые модули не должны зависеть от низкоуровневых. Всё должно зависеть от абстракций.
Прямые зависимости:
public class Cat {
private final DryFood food = new DryFood();
public void eat() {
food.consume();
}
}
public class DryFood {
public void consume() {
System.out.println("Кот ест сухой корм.");
}
}
Теперь Cat
жёстко завязан на DryFood
.
Внедрение зависимостей:
public class Cat {
private final Food food;
public Cat(Food food) {
this.food = food;
}
public void eat() {
food.consume();
}
}
public interface Food {
void consume();
}
public class DryFood implements Food {
@Override
public void consume() {
System.out.println("Кот ест сухой корм.");
}
}
public class WetFood implements Food {
@Override
public void consume() {
System.out.println("Кот ест влажный корм.");
}
}
Теперь вы можно передавать любой тип еды, а Cat
останется неизменным.
SOLID — это не просто пять букв, которые выучили на собеседовании, чтобы забыть сразу после найма. Это принципы, которые превращают ваш код из временной заплатки в прочный фундамент. Это инструменты, которые делают вашу работу осмысленной: вы не боитесь изменений, не тонете в багфиксе, а спокойно добавляете новые фичи.
Код, построенный на принципах SOLID, переживёт не только первый релиз, но и бесконечные «давайте добавим ещё вот это» от бизнеса. Он станет тем проектом, который другие разработчики будут вспоминать с теплотой, а не с дрожью.
Так что, следующий раз, когда будете писать класс или проектировать систему, подумайте о SOLID. Не потому, что это красиво звучит, а потому что это спасёт вас от десятков бессонных ночей.
Статья подготовлена в рамках специализации «Java-разработчик». Прочитать полную программу и посмотреть записи открытых уроков можно на странице курса.