Что такое принципы SOLID? (Пять принципов объектно-ориентированного проектирования SOLID)

Принципы SOLID - это набор из пяти фундаментальных принципов объектно-ориентированного проектирования (OOD), предложенных Робертом К. Мартином.

Принципы SOLID содержат рекомендации по созданию программного обеспечения, которое проще поддерживать и масштабировать по мере роста проекта. Эти принципы помогают избавиться от «запахов кода», упростить рефакторинг и поддержать практики гибкой (Agile) или адаптивной разработки.

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

Что такое принципы SOLID?

Аббревиатура SOLID означает:

  • S: принцип единственной ответственности (Single Responsibility Principle)
  • O: принцип открытости/закрытости (Open-Closed Principle)
  • L: принцип подстановки Лисков (Liskov Substitution Principle)
  • I: принцип разделения интерфейсов (Interface Segregation Principle)
  • D: принцип инверсии зависимостей (Dependency Inversion Principle)

Принцип единственной ответственности

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

Технически это означает, что на структуру класса должен влиять только один тип изменений, например логика работы с базой данных или логика журналирования. Например, если класс представляет сущность данных, такую как Book или Student, его следует обновлять только тогда, когда меняется структура или атрибуты этой сущности.

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

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

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

Пример

Вот пример, демонстрирующий принцип единственной ответственности. Рассмотрим класс Student:

До применения SRP (принципа единственной ответственности) класс Student отвечает и за функции, связанные со студентом, и за отправку уведомлений, что нарушает SRP.

public class Student {
public String getDetails(int studentID) { 
// Logic to fetch student details
}
public void updateGrade(int studentID, int grade) { 
// Logic to update student grade
}
public void sendNotification(String message) { 
// Logic to send notifications to the student
}
}

После применения принципа единственной ответственности мы выносим ответственность за уведомления в новый класс NotificationService:

public class Student {
public String getDetails(int studentID) { 
// Logic to fetch student details
}
public void updateGrade(int studentID, int grade) { 
// Logic to update student grade
}
}
public class NotificationService {
public void sendNotification(String message) { 
// Logic to send notifications to students
}
}

Теперь класс Student сосредоточен только на поведении, связанном со студентом.

Логика уведомлений инкапсулирована в классе NotificationService, что упрощает управление кодом и его изменение.

У каждого класса чёткая и единственная ответственность, что снижает связанность и улучшает сопровождаемость.

Принцип открытости/закрытости

Принцип открытости/закрытости гласит, что «программные сущности (такие как классы, модули или функции) должны быть открыты для расширения, но закрыты для модификации». Это означает, что вы можете добавлять новую функциональность в систему, не изменяя существующий код.

Рассмотрим этот принцип на примере

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

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

Вот как это можно реализовать:

// Base class
public abstract class Shape {
public abstract double calculateArea();
}
// Rectangle class
public class Rectangle extends Shape {
private double length;
private double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override
public double calculateArea() {
return length * width;
}
}
// Circle class
public class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}

В приведённом коде в соответствии с принципом открытости/закрытости:

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

Принцип подстановки Лисков

Принцип подстановки Лисков, сформулированный Барбарой Лисков в 1987 году, гласит, что «подтипы должны быть способны заменять свои базовые типы». Это означает, что любой дочерний класс должен быть способен заменить свой родительский класс, не вызывая неожиданного поведения.

Чтобы понять этот принцип, рассмотрим пример класса Bird (птица). Класс Bird обычно обладает таким поведением, как полёт. Конкретный вид птицы, например пингвин (Penguin), может наследоваться от класса Bird. Однако пингвины не умеют летать, поэтому если поведение полёта наследуется напрямую, это приведёт к нарушению принципа подстановки Лисков.

Пример

Вот как это можно объяснить на коде:

Ниже приведено нарушение принципа подстановки Лисков:

// Parent class
public class Bird {
public void fly() {
System.out.println("Flying...");
}
}
// Child class
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("Penguins can't fly");
}
}

В этом примере подстановка Penguin вместо Bird приводит к неожиданному поведению, поскольку класс Penguin переопределяет метод fly, выбрасывая исключение.

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

// Base class
public abstract class Bird {
public abstract void eat();
}
// Flying behavior
public interface Flyable {
void fly();
}
// Subclass for birds that can fly
public class Sparrow extends Bird implements Flyable {
@Override
public void eat() {
System.out.println("Sparrow is eating...");
}
@Override
public void fly() {
System.out.println("Sparrow is flying...");
}
}
// Subclass for birds that cannot fly
public class Penguin extends Bird {
@Override
public void eat() {
System.out.println("Penguin is eating...");
}
}

Теперь класс Bird сосредоточен только на общем поведении, например на питании.

Поведение полёта изолировано, и только птицы, которые умеют летать, реализуют интерфейс Flyable.

Подстановка любой птицы вместо класса Bird работает так, как ожидается, что сохраняет согласованность и предотвращает ошибки во время выполнения. 

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

Принцип разделения интерфейсов

Принцип разделения интерфейсов касается интерфейсов, а не классов, и тесно связан с принципом единственной ответственности. Он гласит: «Ни один клиент не должен быть вынужден реализовывать интерфейс, который он не использует».

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

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

Пример

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

Теперь предположим, что есть два типа пользователей: посетители, приходящие лично, и онлайн-пользователи.

Посетителям, приходящим лично, нужен только доступ к выдаче и возврату книг.

Онлайн-пользователи сосредоточены на бронировании электронных книг и могут не пользоваться выдачей или возвратом.

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

Нарушение принципа разделения интерфейсов

// Fat interface with unrelated methods
public interface LibraryServices {
void borrowBook();
void returnBook();
void payLateFee();
void reserveEBook();
}
// In-person visitor implements all methods but uses only a few
public class InPersonVisitor implements LibraryServices {
@Override
public void borrowBook() {
System.out.println("Borrowing a book...");
}
@Override
public void returnBook() {
System.out.println("Returning a book...");
}
@Override
public void payLateFee() {
System.out.println("Paying a late fee...");
}
@Override
public void reserveEBook() {
// Not applicable for in-person visitors
throw new UnsupportedOperationException("Not applicable for in-person visitors");
}
}

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

// Smaller, focused interfaces
public interface BookBorrowing {
void borrowBook();
void returnBook();
}
public interface FeePayment {
void payLateFee();
}
public interface EBookReservation {
void reserveEBook();
}
// In-person visitors only implement relevant interfaces
public class InPersonVisitor implements BookBorrowing, FeePayment {
@Override
public void borrowBook() {
System.out.println("Borrowing a book...");
}
@Override
public void returnBook() {
System.out.println("Returning a book...");
}
@Override
public void payLateFee() {
System.out.println("Paying a late fee...");
}
}
// Online users implement only their relevant interface
public class OnlineUser implements EBookReservation {
@Override
public void reserveEBook() {
System.out.println("Reserving an e-book...");
}
}

Приведённый подход гарантирует, что архитектура остаётся чистой и соответствует принципу разделения интерфейсов.

Преимущества соблюдения принципа

  • Каждый клиент работает только с теми интерфейсами, которые к нему относятся. 
  • Сокращает ненужные зависимости.
  • Упрощает сопровождение и расширение системы.
  • Предотвращает потенциальные ошибки во время выполнения, например исключения, выбрасываемые нереализованными методами.

Принцип инверсии зависимостей

Принцип инверсии зависимостей подчёркивает:

«Сущности должны зависеть от абстракций, а не от конкретных реализаций».

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

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

Пример

Рассмотрим NotificationService, который должен отправлять сообщения по разным каналам, например по SMS и электронной почте. Если NotificationService напрямую зависит от конкретных классов для отправки SMS или писем, он становится сильно связанным с этими реализациями. Любые изменения в классах низкого уровня (например, добавление уведомлений в WhatsApp) потребуют изменений в NotificationService, что нарушает принцип.

// High-level module directly depends on low-level modules
public class NotificationService {
private EmailSender emailSender;
private SMSSender smsSender;
public NotificationService() {
this.emailSender = new EmailSender(); // Tight coupling
this.smsSender = new SMSSender(); // Tight coupling
}
public void sendNotification(String message) {
emailSender.sendEmail(message);
smsSender.sendSMS(message);
}
}
class EmailSender {
public void sendEmail(String message) {
System.out.println("Sending Email: " + message);
}
}
class SMSSender {
public void sendSMS(String message) {
System.out.println("Sending SMS: " + message);
}
}

В этом примере NotificationService тесно связан и с EmailSender, и с SMSSender. Добавление нового способа уведомления (например, WhatsAppSender) потребует изменения класса NotificationService, что нарушает принцип инверсии зависимостей.

Чтобы соблюсти принцип инверсии зависимостей, введите абстракцию (интерфейс), от которой будут зависеть и модуль высокого уровня (NotificationService), и модули низкого уровня (EmailSender, SMSSender).

// Abstraction for sending notifications
public interface NotificationSender {
void send(String message);
}
// Low-level module: EmailSender
public class EmailSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Sending Email: " + message);
}
}
// Low-level module: SMSSender
public class SMSSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("Sending SMS: " + message);
}
}
// High-level module: NotificationService
public class NotificationService {
private final NotificationSender notificationSender;
// Dependency injection via constructor
public NotificationService(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
public void sendNotification(String message) {
notificationSender.send(message);
}
}

Пример использования

Теперь NotificationService может работать с любой реализацией интерфейса NotificationSender, не завися напрямую от конкретных классов.

public class Main {
public static void main(String[] args) {
NotificationSender emailSender = new EmailSender();
NotificationService emailService = new NotificationService(emailSender);
emailService.sendNotification("Your email message!");
NotificationSender smsSender = new SMSSender();
NotificationService smsService = new NotificationService(smsSender);
smsService.sendNotification("Your SMS message!");
}
}

Преимущества принципа инверсии зависимостей

  • Снижает связанность между модулями высокого и низкого уровня.
  • Упрощает добавление новых реализаций (например, WhatsAppSender) без изменения существующего кода.
  • Поощряет внедрение зависимостей, что упрощает тестирование и повышает гибкость.

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

Заключение

В этой статье мы рассмотрели пять принципов SOLID для написания чистого и сопровождаемого кода. Если следовать этим принципам, проекты становится проще передавать членам команды, расширять новыми функциями, изменять, тестировать и рефакторить, сводя к минимуму возможные трудности.

Ищете лучший VPS-хостинг? В BlueVPS мы предлагаем мощные, масштабируемые и настраиваемые хостинг-решения с выделенной средой и безлимитным трафиком. Начните уже сегодня и выведите свой хостинг на новый уровень!

Blog