SOLID - основные принципы проектирования в ООП

23 Сентября 2022 (ред)

SOLID

SOLID

Это аббревиатура пяти основных принципов проектирования в объектно-ориентированном программировании

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

Принципы SOLID собраны вместе Робертом Мартиным, вот описание из его книги "Чистая архитектура":

Принципы SOLID имеют долгую историю. Я начал собирать их в конце 1980-х годов, обсуждая принципы проектирования программного обеспечения ... В окончательном виде они были сформулированы в начале 2000-х годов, хотя и в другом порядке, чем я их представлял.

В 2004 году или около того Майкл Физерс прислал мне электронное письмо, в котором сообщил, что если переупорядочить мои принципы, из их первых букв можно составить слово SOLID — так появились принципы SOLID.

Single Responsibility Principle

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

Модуль должен отвечать за одного и только одного актора.

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

Кстати, когда называют паттерн Синглетон анти-паттерном (используя при этом PHP) из-за нарушения принципа единой ответственности в SOLID, мне сразу становится понятно, что этот принцип воспринят буквально и без привязки к языку программирования. Анти-паттерном он может быть из-за увеличения связанности (Coupling) кода в проекте и невозможности создать дополнительный тестовый объект (mock).

Open-closed Principle

Принцип открытости/закрытости был сформулирован Бертраном Мейером в 1988 году. Он гласит:

Программные сущности должны быть открыты для расширения и закрыты для изменения.

В современном прочтении:

Open-closed principle — принцип открытости / закрытости декларирует, что программные сущности (классы, модули, функции и т. п.) должны быть открыты для расширения, но закрыты для изменения. Это означает, что эти сущности могут менять свое поведение без изменения их исходного кода. Следование принципу OCP заключается в том, что программное обеспечение изменяется не через изменение существующего кода, а через добавление нового кода.

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

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

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

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

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

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

Liskov Substitution Principle

Должна быть возможность вместо базового типа подставить любой его подтип.

Роберт К. Мартин "Принципы, паттерны и практики гибкой разработки", 2006

В 1988 году Барбара Лисков написала следующее, с формулировкой определения подтипов:

...если для каждого объекта o1 типа S существует объект o2 типа T такой, что для всех программ P, определенных в терминах T, поведение P не изменяется при замене o2 на o1, то S является подтипом (subtype) для T.

Барбара Лисков "Абстракция данных и иерархия", 1988

На человеческий язык это переводится так:

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

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

В PHP встроено отслеживание вероятных нарушений этого принципа, но не тех, что касаются реализации.

<?php

class A {   
    protected function first(){}    
    public function second(){}  
}

class B extends A {
   /** Расширение видимости класса */
    public function first(){}   
}

class С extends A {
    /** Уменьшение видимости класса */
    protected function second(){}   
}

Например такой код еще на уровне компиляции выдаст ошибку Access level to С::second() must be public (as in class A). Здесь продемонстрировано отображение принципа LSP на примере видимости наследуемых методов. Класс B не выдаёт ошибки, так как он расширяет видимость, включая оригинальное значение. Но вот класс С уменьшил видимость с public до protected, что привело к тому, что класс С нельзя использовать там же, где и класс A.

Interface Segregation Principle

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

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

Dependency Inversion Principle

Роберт Мартин, он же Дядюшка Боб, так описал этот принцип:

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

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

Конечно, добиться полной реализации принципа в проекте будет невозможным, все равно зависимости останутся. Мне удалось найти ещё вот такое определение:

Dependency inversion — принцип инверсии зависимостей, при котором модули верхних уровней не должны зависеть от модулей нижних уровней, а оба типа модулей должны зависеть от абстракций; сами абстракции не должны зависеть от деталей, а вот детали должны зависеть от абстракций. Следование принципу инверсии зависимостей «заставляет» реализовывать высокоуровневые компоненты без встраивания зависимостей от конкретных низкоуровневых классов, что, например, сильно упрощает замену используемых зависимостей как по бизнес-требованиям, так и для целей тестирования. При этом зависимость формируется не от конкретной реализации, а от абстракции — реализуемого зависимостью интерфейса.

2 Ответа

  1. fomiash fomiash 29 Ноября 2023 (ред.)

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

  1. fomiash fomiash 13 Декабря 2023

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



Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.