Рубріки: Теория

Принципы SOLID в объектно-ориентированном программировании

Сергій Бондаренко

Что такое SOLID?

Сегодня говорим об объектно-ориентированном программировании (ООП), а именно — о главных принципах написания кода.

Чем сложнее код, тем тщательнее нужно относиться к его архитектуре. Ошибка думать, что если код справляется с поставленной перед ним задачей — он уже хорош. Нет.

Чтобы в будущем не пришлось тратить бюджет на бесконечные правки, масштабировать проект, искать причину багов и адаптировать код под новые условия работы, помимо основного ТЗ, код должен соответствовать определенным стандартам: как минимум быть удобно читаемым и иметь понятную архитектуру.

Все эти стандарты объединены одним словом — SOLID. Что это такое? Само слово SOLID на английском языке означает «твердый» и отсылает к тому, что код написан по всем правилам: он «тверд» и устойчив к возможным проблемам. Мы постарались по максимуму раскрыть этот вопрос в статье, но если у вас остались вопросы, то рекомендуем вам записаться на курс от наших друзей.

Для чего нужны принципы SOLID

Пожелания SOLID сформировали в начале двухтысячных годов как результат появления методологии объектно-ориентированного программирования (ООП).

Суть ООП в том, что любой программный код — это концепция взаимодействия отдельных информационных объектов. Все эти объекты имеют свою упорядоченность — то есть являются экземплярами класса, а те, в свою очередь, образовывают иерархию наследования.

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

Роберт Мартин, человек, сформировавший принципы SOLID

Первым правила SOLID сформировал человек по имени Роберт Мартин (или «дядя Боб», как он сам себя любит называть).

В сфере разработки программного обеспечения у Роберта Мартина колоссальный опыт — свой код он создавал еще в далеких 70-х, когда программирование только зарождалось. А в девяностых годах он уже имел достаточный багаж знаний, чтобы сформулировать в своих публикациях требования к «хорошему коду».

Термин SOLID — это «изобретение» Майкла Фэзерса, автора книг по программированию. Он обнаружил, что если собрать правила «дяди Боба» воедино, их заглавные буквы составят это слово. Подходы к разработке SOLID — это абстрактные сущности, которые не привязаны к конкретному языку программирования. Они содержат такие рекомендации для разработчиков:

  • S (SRP)Single-responsibility principle (единственная ответственность). Любой класс должен иметь одну зону ответственности.
  • OOpen–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: абстракции не должны зависеть от деталей; детали должны зависеть от абстракций. Необходимо использовать все классы через интерфейсы. 

Когда идет обращение из одного класса к другому (скажем, клиент-сервер) и требуется добавить некоторую функциональность (авторизацию, аутентификацию, логирование, кеширование и пр.) у вас есть два пути:

  1. вы можете разорвать эту зависимость (разыскивая в коде, где именно было обращение);
  2. либо вы можете встраивать изменения прямо в код сервера.

Код серверного класса и так содержит достаточно тяжелый функционал, а добавление нового нарушает другой принцип проектирования — SRP.

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

Одним словом, чтобы всех этих проблем избежать — нужно следовать принципу DIP. 

Вывод

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

Код, который трудно переиспользовать, разрастается в диком количестве — его просто начинают копировать и объем увеличивается до невменяемых размеров. SOLID-подход к проектированию ПО помогает эту проблему избежать.

Любопытный момент: рекомендации SOLID не ограничиваются приведенными правилами. Принципов проектирования ПО Роберт Мартин изложил гораздо больше. Если вам это интересно, остальные паттерны проектирования вы найдете в его книге.

В завершение разговора о SOLID, рекомендуем вам посмотреть видео:

Останні статті

Обучение Power BI – какие онлайн курсы аналитики выбрать

Сегодня мы поговорим о том, как выбрать лучшие курсы Power BI в Украине, особенно для…

13.01.2024

Work.ua назвал самые конкурентные вакансии в IТ за 2023 год

В 2023 году во всех крупнейших регионах конкуренция за вакансию выросла на 5–12%. Не исключением…

08.12.2023

Украинская IT-рекрутерка создала бесплатный трекер поиска работы

Unicorn Hunter/Talent Manager Лина Калиш создала бесплатный трекер поиска работы в Notion, систематизирующий все этапы…

07.12.2023

Mate academy отправит работников в 10-дневный оплачиваемый отпуск

Edtech-стартап Mate academy принял решение отправить своих работников в десятидневный отпуск – с 25 декабря…

07.12.2023

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

Служба безопасности Украины задержала в Киеве 46-летнего программиста, который за деньги устанавливал шпионские программы и…

07.12.2023

Как вырасти до сеньйора? Девелопер создал популярную подборку на Github

IT-специалист Джордан Катлер создал и выложил на Github подборку разнообразных ресурсов, которые помогут достичь уровня…

07.12.2023