Принципы SOLID в объектно-ориентированном программировании
Содержание
Что такое SOLID?

Чем сложнее код, тем тщательнее нужно относиться к его архитектуре. Ошибка думать, что если код справляется с поставленной перед ним задачей — он уже хорош. Нет.
Чтобы в будущем не пришлось тратить бюджет на бесконечные правки, масштабировать проект, искать причину багов и адаптировать код под новые условия работы, помимо основного ТЗ, код должен соответствовать определенным стандартам: как минимум быть удобно читаемым и иметь понятную архитектуру.
Все эти стандарты объединены одним словом — SOLID. Что это такое? Само слово SOLID на английском языке означает «твердый» и отсылает к тому, что код написан по всем правилам: он «тверд» и устойчив к возможным проблемам. Мы постарались по максимуму раскрыть этот вопрос в статье, но если у вас остались вопросы, то рекомендуем вам записаться на курс от наших друзей.
Для чего нужны принципы SOLID
Пожелания SOLID сформировали в начале двухтысячных годов как результат появления методологии объектно-ориентированного программирования (ООП).
Суть ООП в том, что любой программный код — это концепция взаимодействия отдельных информационных объектов. Все эти объекты имеют свою упорядоченность — то есть являются экземплярами класса, а те, в свою очередь, образовывают иерархию наследования.
Принцип ООП быстро распространился, потому что программистам стало намного легче реализовывать большие и сложные проекты, моделировать алгоритмы и управлять информацией.

Роберт Мартин, человек, сформировавший принципы SOLID
Первым правила SOLID сформировал человек по имени Роберт Мартин (или «дядя Боб», как он сам себя любит называть).
В сфере разработки программного обеспечения у Роберта Мартина колоссальный опыт — свой код он создавал еще в далеких 70-х, когда программирование только зарождалось. А в девяностых годах он уже имел достаточный багаж знаний, чтобы сформулировать в своих публикациях требования к «хорошему коду».
Термин SOLID — это «изобретение» Майкла Фэзерса, автора книг по программированию. Он обнаружил, что если собрать правила «дяди Боба» воедино, их заглавные буквы составят это слово. 
- S (SRP) — Single-responsibility principle (единственная ответственность). Любой класс должен иметь одну зону ответственности.
- O — Open–closed principle (открытость и закрытость). Классы можно расширять, но желательно напрямую их не модифицировать. Другими словами, код, который уже создан, не должен подвергаться правкам. Разработчик имеет право только добавить что-то или исправить обнаруженные ошибки.
- L (LSP) — Liskov substitution principle (правило подстановки Барбары Лисков). Этот принцип самый трудный для понимания и немного абстрактный. Речь идет о логичности наследования; о том, что класс-предок можно поменять на дочерний, не ломая логику работы программы.
- I (ISP) — Interface segregation principle (разделение интерфейса). Суть этого принципа в преимуществе интерфейса, специально предназначенного для клиентов по сравнению с единым интерфейсом общего назначения для всех.
- D (DIP) — Dependency inversion principle (правило инверсии зависимостей). Более высокоуровневые модули не должны зависеть от более низкоуровневых, а в идеале они должны зависеть от некоторых абстракций. Детали не должны оказывать влияние на абстракции, а скорее абстракции должны влиять на детали.
Так как эти принципы используются при проектировании реальных приложений, проще всего их понять именно на примерах. Давайте их и рассмотрим.
S — принцип единственной ответственности
Зона ответственности у одного класса обязана быть единственной.
Например, если в коде класса есть два метода, один из которых подготавливает и форматирует данные, а другой записывает, то такой класс нарушает принцип SRP. Его нужно разбивать на два класса, каждый их которых выполняет свою функцию.

Вы создаете класс, который реализует взаимодействие как с температурным датчиком, так и с датчиком влажности. К вам приходит коллега, который работает над ПО для термостабилизации насосов. Он просит у вас ему помочь и вы, добрая душа, делитесь с ним своим классом. Он его редактирует, отбирая для своего кода нужную часть класса.
Тем временем к вам со схожей проблемой обращается другой коллега и просит класс для реализации ПО, которое следит за уровнем влажности в теплице. Он тоже редактирует код, забирая нужный фрагмент для своих нужд.
Тут внезапно вы обнаруживаете ошибку в своем классе. Что же делать? Можно, конечно, обратиться к своим коллегам и сообщить, что вы переделали свой класс, а они тогда заново станут переделывать его под свои потребности.
Но можно было поступить иначе — готовить код согласно принципу SRP. Тогда у вас изначально будет не один класс, а два.
В этом случае подобных проблем вообще не возникнет — первому коллеге вы предоставите класс с функциональностью по датчику влажности, а второму — часть кода с поддержкой термодатчика. А если в каком-то из классов обнаружится ошибка, ее можно будет быстро исправить.
O — принцип открытости/закрытости
OCP — это принцип, при котором любые программные сущности должны быть открыты для расширения, но при этом запрещены для внесения любых правок.
Это означает, что любой класс, любой метод, а также любой блок программного кода должен быть открыт для добавление дополнительной функциональности.
Добавлять функциональность, не изменяя существующей — идея, на первый взгляд, спорная. Но смысл этого принципа сводится к тому, чтобы уменьшить количество возможных багов и серьезно снизить расходы на разработку ПО в целом.
Давайте посмотрим на примере.
Предположим, вы сделали ПО, протестировали его и через некоторое время задались целью расширить его функциональность. В этом случае любые ошибки, которые возникнут в процессе разработки, нужно будет искать только в той части кода, который вы добавляете. Уменьшается объем работы у тестировщиков — им не нужно каждый раз проводить регрессионное тестирование всего кода. Хорошим тестировщиком можно стать начав свой путь с курсов наших партнеров Robot Dreams и Powercode.
Реализовать этот принцип можно двумя способами.
Первый вариант предлагает Бертран Мейер, который и придумал этот принцип в 1988 году в своей книге Object-Oriented Software Construction, а Роберт Мартин лишь позаимствовал его.
Вы создаете некоторый кусок кода (скажем, класс) и закрываете его от любых изменений, кроме баг-фиксов. Расширить такой код можно с помощью наследования. Вы создаете Code2 и наследуете его от Code1, добавляя в него функциональность. При этом интерфейс в блоке Code2 можно изменять.

Как предлагает реализовать принцип OCP Бертран Мейер
Но это несколько странно и неудобно, и существует высокая вероятность появления побочных проблем (сайд-эффект). Также, если некоторая клиентская часть кода, которая раньше обращалась к Code1, теперь обращается к Code2 и получает новый интерфейс, ее тоже нужно обновить.
Второй вариант — полиморфный — придумал сам «дядя Боб». Он предлагает подойти к вопросу с другой стороны.
Клиентский софт должен зависеть от неизмененного интерфейса. Новая же реализация старого кода должна использовать тот же интерфейс, возможно, делегируя каким-то образом вызов старого кода. Также она может наследоваться от него — в этом случае клиентский код не нужно переписывать.

Как предлагает реализовать принцип OCP Роберт Мартин
Трактовка Роберта Мартина более логична, поэтому в большинстве случаев, когда говорят о принципе открытости/закрытости, имеют в виду именно полиморфный подход. Крэг Ларман, который собрал шаблоны GRASP, активно использующиеся сегодня в ООП, отнес OCP к шаблону Protected Variations — по сути, это одно и то же.
Моделируя работу клиентского кода с серверной частью, теоретически вы можете просто обратиться к какому-то классу напрямую. Желание так сделать нарушает OCP — расширить такое взаимодействие нельзя никак.
Чтобы решить эту проблему, мы должны вынести интерфейс сервера в отдельный блок и сделать так, чтобы клиентская часть зависела только от этого модуля, а не от сервера. Расширить возможности можно при помощи любого GoF-паттерна (о том, что это такое — читайте тут)
L — принцип подстановки Лисков
Это правило — о «правильном наследовании». Его придумала американская ученая-информатик Барбара Лисков.

Барбара Лисков, авторка принципа подстановки Лисков
У любого объекта есть тип, то есть класс. У разных объектов могут быть разные типы и они могут принадлежать одному классу объектов. Сами классы выстраиваются в иерархию классов и должны наследовать функциональность своих предков.
Подход Барбары Лисков помогает обнаруживать проблемные абстракции и скрытые связи между сущностями, делать поведение модулей предсказуемым и вводить ограничения на наследование.
Принцип говорит, что поведение на следующих классах не должно противоречить поведению заданным классам. Сущности, которые применяют родительский тип, должны точно так же работать и с дочерними классами. При этом в логике функционирования приложения не должно ничего ломаться.
Перефразируя сказанное — наследуемый класс должен дополнять, а не замещать поведение базового класса. Смысл в том, чтобы проектировать логику так, чтобы классы-наследники могли спокойно использоваться вместо родителей. Но в большинстве случаев из-за дополнительных проверок логики, для обоих классов лучше всего использовать общий интерфейс, а не наследовать один класс от другого.
Простейший наглядный пример, который может это продемонстрировать, выглядит так.
У нас есть два класса: (1) прямоугольник и (2) квадрат. Класс прямоугольник принимает два числа — высоту и ширину. Кроме того, он содержит три метода:
- задание высоты;
- установка ширины;
- и расчет площади геометрической фигуры.
class Rectangle {
constructor(public width: number, public height: number) {}
setWidth(width:number){
this.width = width;
}
setHeight(height:number){
this.height = height;
}
totalareaOf(): number {
return this.width * this.height
}
}
class Square extends Rectangle {
width: number = 0;
height: number = 0;
constructor(size: number) {
super(size, size);
}
setWidth(width:number) {
this.width = width;
this.height = width;
}
setHeight(height:number){
this.height = height;
this.width = width;
}
}
Поскольку эта фигура — частный случай фигуры Rectangle, он наследуется от соответствующего класса и определяет методы.
Переопределение методов нужно обязательно. Если этого не сделать, у нас будут неверно рассчитываться стороны, потому что класс-потомок будет брать методы родительского класса. Очевидно, что принцип подстановки Лисков нарушается.
Конечно, мы можем определить инстанс (экземпляр класса) квадрата:
const mySquare = new Square(20); //ширина и высота - 20 mySquare.setWidth(30); //ширина и высота - 30 mySquare.setWidth(40); //ширина и высота - 40
Но когда мы, работая с классом Square, станем использовать в качестве интерфейса Rectangle, возникнут проблемы:
//Как должно работать
const changeShapeSize = (figure:Rectangle): void => {
figure.setWidth(10); //ширина 10, высота - 0
figure.setHeight(20); //ширина 10, высота - 20
}
//Как это работает
const changeShapeSize = (figure:Rectangle): void => {
figure.setWidth(10); //ширина 10, высота - 10
figure.setHeight(20); //ширина 20, высота - 20
}
Из-за того, что мы используем класс квадрата, вместо того, чтобы каждое из полей устанавливать по отдельности, мы одним махом изменяем оба параметра.
Без дополнительных проверок подкласс не может заменить суперкласс. В этом случае нужно создать единый интерфейс для пары классов и вместо наследования одного класса от другого, задействовать интерфейс.
I — принцип разделения интерфейса
Этот подход перекликается с первым принципом SOLID — SRP.
При наследовании класс-потомок может получить кучу ненужной функциональности, которая в нем не используется. Чтобы избежать такой проблемы, интерфейсы принято декомпозировать.
Как это выглядит на практике?
Предположим, мы имеем дело с интефейсом AutoSet, который содержит три простых метода для разных автомобилей:
interface AutoSet {
getMercedesSet(): any;
getRenaultSet(): any;
getZaporozhetsSet(): any;
}
Если сейчас написать класс, наследуя от интерфейса, в нем будут содержаться все методы, включая два бесполезных:
class MercedesImplements AutoSet {
getMercedesSet(): any { };
getRenaultSet(): any { };
getZaporozhetsSet(): any { };
}
class ZaporozhetsImplements AutoSet {
getMercedesSet(): any { };
getRenaultSet(): any { };
getZaporozhetsSet(): any { };
}
class RenaultImplements AutoSet {
getMercedesSet(): any { };
getRenaultSet(): any { };
getZaporozhetsSet(): any { };
}
Добавляя метод, мы должны будем вносить изменения во все классы-потомки. Поэтому будет логично разделить интерфейсы:
interface MercedesSet {
getMercedesSet(): any;
}
interface RenaultSet {
getRenaultSet(): any;
}
interface ZaporozhetsSet {
getZaporozhetsSet(): any;
}
class Mercedes implements MercedesSet {
getMercedesSet(): any { };
}
class Renault implements RenaultSet {
getRenaultSet(): any { };
}
class Zaporozhets implements ZaporozhetsSet {
getZaporozhetsSet(): any { };
}
Таким образом у нас есть три основных интерфейса, которые не зависят друг от друга и содержат описание будущей имплементации.
Затем мы создаем от каждого интерфейса класс-потомок, который на этот раз содержит только необходимую ему функциональность.
Преимущества рекомендации ISP:
- снижает зависимости между модулями;
- при наследовании отсутствует ненужный функционал, который необходимо реализовывать;
- в процессе внесения правок затрагиваются только нужные части, а не все зависящие модули.
D — принцип инверсии зависимостей
Эта рекомендация описывает важнейший принцип ООП: модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
Альтернативный вариант изложения подхода DIP: абстракции не должны зависеть от деталей; детали должны зависеть от абстракций. Необходимо использовать все классы через интерфейсы.
Когда идет обращение из одного класса к другому (скажем, клиент-сервер) и требуется добавить некоторую функциональность (авторизацию, аутентификацию, логирование, кеширование и пр.) у вас есть два пути:
- вы можете разорвать эту зависимость (разыскивая в коде, где именно было обращение);
- либо вы можете встраивать изменения прямо в код сервера.
Код серверного класса и так содержит достаточно тяжелый функционал, а добавление нового нарушает другой принцип проектирования — SRP.
Теоретически можно организовать, чтобы сервер сам вызывал добавляемый кусок кода. Но это плохая идея. Впоследствии может оказаться, что для некоторых клиентских вызовов он вообще не нужен, и тогда придется использовать какие-то флаги, которые определяют, когда именно сервер будет делать вызов.
Одним словом, чтобы всех этих проблем избежать — нужно следовать принципу DIP.
Вывод
Соблюдение принципов SOLID гарантирует легкое обновление вашей программы. Расширять, изменять и поддерживать ее будет просто за счет того, что ваш код будет легко читать, а все ошибки в нем будут прекрасно видны.
Код, который трудно переиспользовать, разрастается в диком количестве — его просто начинают копировать и объем увеличивается до невменяемых размеров. SOLID-подход к проектированию ПО помогает эту проблему избежать.
Любопытный момент: рекомендации SOLID не ограничиваются приведенными правилами. Принципов проектирования ПО Роберт Мартин изложил гораздо больше. Если вам это интересно, остальные паттерны проектирования вы найдете в его книге.
В завершение разговора о SOLID, рекомендуем вам посмотреть видео:


Сообщить об опечатке
Текст, который будет отправлен нашим редакторам: