SOLID на котиках: коротко и по делу

bd747fe4119e9e8b60c5ad9c5ff11bd7.png

Каждый разработчик знает, каково это — увидеть код, который страшно трогать. В нём всё ломается, стоит добавить пару строк. Чтобы такого не было, мир придумал 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-разработчик». Прочитать полную программу и посмотреть записи открытых уроков можно на странице курса.

© Habrahabr.ru