SOLID principles

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

SOLID подробнее: https://solidbook.vercel.app/

S – Single Responsibility Principle (SRP)

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

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

Назначение

Служит для разделения поведения разных классов, так чтобы разное поведение не пересекалось.

Пример

Неправильно:

class User {
	public $username;
	public $email;

	public function __construct($username, $email) {
		$this->username = $username;
		$this->email = $email;
	}

	public function save() {
		// Логика сохранения пользователя в базе данных
	}

	public function sendEmail($message) {
		// Логика отправки электронной почты пользователю
	}

	public function generateReport() {
		// Логика генерации отчета пользователя
	}
}

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

Правильно:

Разделим класс User на несколько отдельных классов с единой ответственностью:

class User {
	public $username;
	public $email;

	public function __construct($username, $email) {
		$this->username = $username;
		$this->email = $email;
	}

	public function save() {
		// Логика сохранения пользователя в базе данных
	}
}

class EmailSender {
	public function send_email($user, $message) {
		// Логика отправки электронной почты пользователю
	}
}

class ReportGenerator {
	public function generate_report($user) {
		// Логика генерации отчета пользователя
	}
}

Видео

O — Open-Closed Principle (OCP)

Классы должны быть открыты для расширения, но закрыты для изменения.

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

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

Назначение

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

Пример

Например, обработка разных способов оплаты в checkout.

Плохой вариант:

class CheckoutService {
	public function process_payment( string $payment, array $order ): void {
		if ( 'card' === $payment ) {
			// Оплата картой.
		}

		if ( 'paypal' === $payment ) {
			// Оплата через PayPal.
		}
	}
}

Если появится Apple Pay, Google Pay или bank transfer, придется менять CheckoutService.

По OCP лучше так:

interface PaymentMethod {
	public function pay( array $order ): void;
}

class CardPayment implements PaymentMethod {
	public function pay( array $order ): void {
		// Оплата картой.
	}
}

class PaypalPayment implements PaymentMethod {
	public function pay( array $order ): void {
		// Оплата через PayPal.
	}
}

class CheckoutService {
	public function process_payment( PaymentMethod $payment, array $order ): void {
		$payment->pay( $order );
	}
}

Теперь можно добавить новый способ оплаты без изменения CheckoutService:

class ApplePayPayment implements PaymentMethod {
	public function pay( array $order ): void {
		// Оплата через Apple Pay.
	}
}

Это упрощенный пример. Что в нем есть правильно:

  • новый способ оплаты добавляется новым классом
  • CheckoutService не зависит от конкретных реализаций
  • существующий код не нужно менять при добавлении нового варианта

Что там упрощено или не полно:

  • сам по себе интерфейс еще не гарантирует хороший OCP
  • важна точка расширения в правильном месте
  • создание объекта тоже где-то должно происходить, и если для добавления нового способа оплаты тебе надо лезть в огромный switch в другом месте, то OCP уже соблюден не полностью
  • в реальном приложении обычно есть не только pay(), но и валидация, поддержка refunds, availability, config, error handling

То есть пример верно передает суть OCP, но не покрывает всю картину.

OCP обычно реализуется через

  • наследование
  • полиморфизм
  • композицию
  • паттерн Strategy
  • инъекцию зависимостей

Видео

TODO: video_here

L — Liskov Substitution Principle (LSP)

Объекты (классы) можно заменить на под-объекты (дочерние классы) при этом программа не должна сломаться.

Если объект "B" был создан из объекта "A", то любые объекты "A" в программе, должны заменяться объектами "B" без негативных последствий для функциональности программы.

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

Если вы создаете новый класс на базе другого, исходный класс становится родителем, а новый – потомком. Необходимо, чтобы потомок умел делать все точно так же, как и родитель. Например, при перегрузке метода, новый метод должен уметь все тоже самое что и исходный метод (получать такие же параметры, возвращать такие же данные/типы) и только потом делать что-то еще.

Если потомок не удовлетворяет этим требованиям, значит, он слишком сильно отличается от родителя и нарушает принцип LSP.

Назначение

Служит для обеспечения постоянства: потомок может быть использован вместо родителя без нарушения работы программы.

Видео

I — Interface Segregation Principle (ISP)

Клиент не должн зависеть от интерфейсов с методами, которые ему не нужны.

Лучше несколько маленьких интерфейсов, чем один большой универсальный.

Если классу или клиенту нужна только часть поведения, он должен зависеть только от этой части.

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

Назначение

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

Так каждый класс реализует только то, что ему действительно нужно.

Простой пример

interface Workable {
	public function work();
}

interface Eatable {
	public function eat();
}

class Human implements Workable, Eatable {
	public function work() { ... }

	public function eat() { ... }
}

class Robot implements Workable {
	public function work() { ... }
}

В этом примере Human умеет работать и есть, поэтому реализует оба интерфейса.
Robot умеет только работать, поэтому реализует только Workable.

Если бы был один общий интерфейс Worker с методами work() и eat(), класс Robot пришлось бы заставлять реализовывать eat(), хотя этот метод ему не нужен. Это и было бы нарушением принципа.

Более похожий на реальную жизнь пример

Разные типы пользователей в админке.

Плохо:

interface AdminUserInterface {
	public function view_dashboard(): void;
	public function edit_posts(): void;
	public function publish_posts(): void;
	public function manage_users(): void;
	public function manage_settings(): void;
}

Например, редактору не нужны:

  • manage_users()
  • manage_settings()

Но если класс EditorUser реализует этот интерфейс, ему придется тащить лишние методы.

class EditorUser implements AdminUserInterface {
	public function view_dashboard(): void {}

	public function edit_posts(): void {}

	public function publish_posts(): void {}

	public function manage_users(): void {
		throw new RuntimeException( 'Not allowed.' );
	}

	public function manage_settings(): void {
		throw new RuntimeException( 'Not allowed.' );
	}
}

Хорошо:

interface DashboardAccessInterface {
	public function view_dashboard(): void;
}

interface ContentManagementInterface {
	public function edit_posts(): void;
	public function publish_posts(): void;
}

interface UserManagementInterface {
	public function manage_users(): void;
}

interface SettingsManagementInterface {
	public function manage_settings(): void;
}

Теперь классы берут только нужные наборы методов:

class EditorUser implements DashboardAccessInterface, ContentManagementInterface {
	public function view_dashboard(): void {}

	public function edit_posts(): void {}

	public function publish_posts(): void {}
}

class AdministratorUser implements DashboardAccessInterface, ContentManagementInterface, UserManagementInterface, SettingsManagementInterface {
	public function view_dashboard(): void {}

	public function edit_posts(): void {}

	public function publish_posts(): void {}

	public function manage_users(): void {}

	public function manage_settings(): void {}
}

Смысл

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

Видео

TODO: video here

D — Dependency Inversion Principle (DIP)

Модули верхнего уровня не должны зависеть от модулей нижнего уровня. И те, и другие должны зависеть от абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

  • Абстракция = интерфейс, соединяющий два класса.
  • Модули (классы) верхнего уровня = классы, которые выполняют операцию при помощи инструмента.
  • Модули (классы) нижнего уровня = инструменты, которые нужны для выполнения операций.
  • Детали = специфический код внутри инструмента - реализация конкретной задачи в коде.

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

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

Назначение

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

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

Пример кода

Давайте рассмотрим пример на python с классами Notification и EmailSender. Данный код нарушает принцип DIP:

interface Notification {
	public function sendNotification( $message );
}

class EmailSender implements Notification {
	public function sendNotification( $message ) {
		// send notification via email
	}
}

class SMSNotification implements Notification {
	public function sendNotification( $message ) {
		// send notification via SMS
	}
}

class User {
	private $username;
	private $email;
	private $notificationService;

	public function __construct( $username, $email ) {
		$this->username = $username;
		$this->email = $email;

		// Change this to use desired notification service
		$this->notificationService = new EmailSender();

	}

	public function sendNotification($message) {
		$this->notificationService->sendNotification($message);
	}
}

В этом примере класс User напрямую зависит от конкретной реализации EmailSender в качестве сервиса уведомлений.

Чтобы применить SOLID принцип DIP, изменяем User, чтобы он зависел от абстракции Notification, а не от конкретной реализации:

class User {
	private $username;
	private $email;
	private $notificationService;

	public function __construct( $username, $email, $notificationService ) {
		$this->username = $username;
		$this->email = $email;
		$this->notificationService = $notificationService;
	}

	public function sendNotification( $message ) {
		$this->notificationService->sendNotification( $message );
	}
}

Теперь User принимает объект notification_service, реализующий интерфейс Notification. Это позволяет передавать различные реализации уведомлений, например EmailSender или SMSNotification, без изменения самого User:

$emailSender = new EmailSender();
$user1 = new User( "John", "john@example.com", $emailSender );
$user1->sendNotification( "Hello!" );

$smsNotification = new SMSNotification();
$user2 = new User( "Jane", "jane@example.com", $smsNotification );
$user2->sendNotification( "Hi there!" );

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

Видео

--

--

Фото были взяты здесь: https://habr.com/ru/company/productivity_inside/blog/505430/