Давайте глянем на определение принципа инверсии зависимостей из википедии:
Принцип инверсии зависимостей (англ. dependency inversion principle, DIP) — важный принцип объектно-ориентированного программирования, используемый для уменьшения связанности в компьютерных программах. Входит в пятёрку принципов SOLID.
Формулировка:
A. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
B. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Большинство разработчиков, с которыми мне доводилось общаться, понимают только вторую часть определения. Мол "ну а что тут такого, надо завязывать классы не на конкретную реализацию а на интерфейс". И вроде бы верно, но только кому должен принадлежать интерфейс? Да и почему вообще этот принцип так важен? Давайте разбираться.
Модули
модуль — логически взаимосвязанная совокупность функциональных элементов.
Что бы не было недопонимания, введем немного терминологии. Под модулем мы будем понимать любую функционально связанную часть системы. Например фреймворк мы можем поместить как отдельный независимый модуль, а логику работы с пользователями — в другой.
Модуль, это ничто иное, как элемент декомпозиции системы. Модуль может включать в себя другие модули, формируя что-то вроде дерева. Соответственно можно выделить модули разных уровней:
Здесь стрелочки между модулями показывают кто что использует. Соответственно эти же стрелочки будут показывать нам направления зависимостей между нашими модулями.
И вот пришло время добавить "еще одну кнопочку". И мы понимаем что функционал этой кнопки реализован в модуле E. Мы не раздумывая полезли добавлять то что нам надо, и нам пришлось поменять интерфейс взаимодействия с нашим модулем.
Мы уже хотели закрыть задачу, закоммитить код… но мы же что-то поменяли… пойдем смотреть не сломали ли мы кого. И тут оказывается что из-за наших изменений сломался модуль B. Окей. Починили. А вдруг кто-то кто использует модуль B тоже сломался? И в правду! Модуль A тоже отвалился. Чиним… Коммитимся, пушим. Хорошо если есть тесты, тогда о проблемы мы узнаем быстро и быстро сможем исправить. Но давайте посмотрим правде в глаза, мало кто пишет тесты.
А еще коллеге вашему прилетел баг от тестировщика, мол модуль C сломался. Оказалось что он по неосторожности завязался на ваш модуль E, а вам об этом не сказал. Да еще и модуль этот состоит из кучи файлов, и всем от модуля E что-то нужно. И вот теперь и он лазает по своей части графа зависимостей (потому что ему проще в нем ориентироваться чем вам, не ваша же часть системы) и проклинает вас.
На рисунке выше, оранжевый кружочек обозначает модуль, который мы хотели поправить. А красные — которые пришлось поправить. И не факт что каждый кружок — один класс. Это могут быть целые компоненты. И хорошо если модулей у нас не сильно много и они не сильно пересекаются между собой. А что если у нас каждый кружочек был бы связан с каждым? Это ж чинить все на любой чих. И в итоге простая задача "кнопочку добавить" превращается в рефакторинг куска системы. Как быть?
Интерфейсы и позднее связывание
Позднее связывание означает, что объект связывается с вызовом функции только во время исполнения программы, а не на этапе компиляции.
Как известно, интерфейсы определяют некий контракт. И каждый объект, реализующий этот контракт, обязан его соблюдать. Например пишем мы регистрацию пользователей. И вспоминаем требование — пароль пользователя должен быть надежно захэширован на случай утечки данных из базы. Предположим что в данный момент мы не знаем как правильно это делать. И предположим что мы еще не выбрали фреймворк или библиотек для того чтобы делать проект. Безумие, я знаю… Но давайте представим что у нас сейчас нет ничего, кроме логики приложения.
Мы вспоминаем о требовании, но не бросать же нам все? Давайте все же сначала закончим с регистрацией пользователя, а уж потом будем разбираться как чего делать. Надо все же последовательно подходить к работе. А потому вместо того чтобы гуглить "как правильно хэшировать пароль" или разбираться как это делать в нашем фреймворке, давайте сделаем интерфейс PasswordEncoder
. Сделав это, мы создадим "контракт". Мол всякий кто решится реализовать этот интерфейс, обязан предоставить надежное и безопасное хэширование пароля. Сам же интерфейс будет до безумия простым:
interface PasswordEncoder
{
public function encode(string $password): string;
}
Это именно то, что нам нужно для работы в данный момент времени. Мы не хотим знать как это будет происходить, мы еще не знаем про соль и медленное хэширование. Мы можем сделать сделать заглушку, которая будет на момент разработки возвращать то, что мы запихнули. А уж потом сделаем нормальную реализацию. Точно так же мы можем поступить с отправкой email-а о том что мы успешно зарегистрировали пользователя. Мы можем даже параллельно посадить еще людей, которые будут эти интерфейсы реализовывать для нас, что бы дело быстрее шло. Красота.
А прелесть в том, что мы можем динамически заменить реализацию. То есть непосредственно перед вызовом регистрации пользователя выбрать, какой энкодер паролей нам надо использовать. Именно это подразумевается под поздним связыванием. Возможность "выбрать" реализацию прямо перед использованием оной.
В языках с динамической системой типов, такой как в PHP, есть еще более простой способ добиться позднего связывания — не использовать тайп хинтинг. От слова совсем. Правда сделав это, мы полностью потеряем статическую (представленную явно в коде) информацию о том, кто что использует. И когда мы что-то поменяем, нам уже не выйдет так просто определить, не сломался ли код. Это как выключить свет и искать парные носки в горе из 99 одного левого и 1-ого правого.
Инверсия зависимостей
Итак, мы уже определились что модуль E все ломает. И ваш коллега захотел защититься от будущих изменений в "чужом" коде. Как никак, он из этого модуля использует только одну функцию.
Для этого в своем модуле C он создал интерфейс, и написал простенький адаптер, который принимает зависимость из нужного модуля и предоставляет доступ только к нужному методу. Теперь если вы что-то поправите — исправить "поломку" можно будет в одном месте.
Причем этот интерфейс расположен на границе модуля C, когда адаптер — на границе модуля E. Мол когда разработчику модуля E взбредет в голову поправить свой код, ему придется починить наш адаптер.
Ну а мы решили что скоро вообще перепишем этот модуль и нам так же стоит защитить наш зависимый модуль. Поскольку мы то используем из модуля E побольше, то интерфейс вашего коллеги нам не годится. Нам нужно реализовать свой. Нам так-же придется реализовать этот интерфейс в рамках модуля E, дабы потом, когда мы будем переписывать его, не забыть подправить реализацию. Взглянем что у нас вышло:
Очень важно то, что у нас два интерфейса, а не один. Если бы мы поместили интерфейс в модуль E, мы бы не устранили зависимости между модулями. Тем более, разным модулям требуются разные возможности. Наша задача изолировать ровно ту часть, которую мы собираемся использовать. Это значительно упростит поддержку.
Так же, если вы посмотрите на картинку выше, вы можете заметить, что поскольку реализация адаптеров лежит в модуле E, теперь этот модуль вынужден реализовывать интерфейсы из других модулей. Тем самым мы инвертировали направление стрелочки, указывающей зависимость. Мы инвертировали зависимости.
Не все зависимости стоят того, чтобы их инвертировать
Модули теперь меньше связаны между собой, чего мы собственно и добивались. Мы не стали делать это для всего, поскольку изменений в других модулях ближайшие пару лет не предвидится. Не стоит волноваться об изменениях в том, что редко меняется. А вот если у вас есть куски системы, которые меняются часто, или вы просто сейчас не знаете что там будет по итогу, имеет смысл защититься от возможных изменений.
К примеру, если нам понадобится логгер, мы всегда сможем использовать интерфейс PSRLogger
поскольку он стандартизирован, а такие вещи крайне редко меняются. Затем мы сможем выбрать любой логгер реализующий этот интерфейс на наш вкус:
Как вы можете видеть, благодаря этому интерфейсу, наше приложение все еще не зависит от конкретного логгера. Логгер же зависит от этой абстракции. Но оба "модуля" не зависят друг от друга.
Изоляция
Интерфейсы и позднее связывание позволяют нам "абстрагировать" реализацию логики от посторонних деталей. Мы должны стараться делать модули как можно более изолированными и самодостаточными. Когда все модули независимы, мы получаем возможность и независимо их развивать. А это может быть важно с точки зрения бизнеса.
Часто, когда речь заходит об абстракциях, люди любят доводить все до крайности, забывая зачем изначально все это нужно.
Когда проект планируется поддерживать намного дольше, нежели период поддержки вашего фреймворка, имеет смысл все используемые вещи завернуть в адаптеры. Это своего рода крайность, но в таких условиях она оправдана. Менять фреймворк мы врядли будем, а вот обновить мажорную версию в будущем без боли мы пожалуй бы хотели.
Или к примеру еще одно распространенное заблуждение — абстракция от хранилища. Возможность полной замены базы данных ни в коем случае не является целью реализации этой абстракции, это скорее критерий качества. Вместо этого мы просто должны дать такой уровень изоляции, чтобы наша логика не зависела от возможностей базы данных. Причем это не значит что мы не должны пользоваться этими возможностями.
К примеру мы реализовали поиск в нашей любимой MySQL, но в итоге потребовался более качественная реализация. И мы решили взять ElasticSearch для этого, просто потому, что с ним поиск делать быстрее. Отказываться от MySQL мы так же не можем, но благодаря выстроенной абстракции, мы можем добавить еще одну базу данных, чтобы эффективнее выполнить конкретную задачу.
Или мы делаем очередную соц сеть, и нам надо как-то трекать репосты. Да, мы можем сделать это на MySQL но выйдет неудобно. Тут напрашиваются графовые базы данных. И таких сценариев массы. Мы должны руководствоваться здравым смыслом в первую очередь а не догмами.
На этом пожалуй все. Я уверен что я не все сказал и могут остаться вопросы, потому не стесняемся задавать их в комментариях. Так же я уверен, что знаю отнюдь не все, и буду рад комментариям раскрывающих тему чуть глубже или примеры из жизни, когда инверсия зависимости помогла или могла бы помоч. Ну и если вы нашли опечатки/ошибки в статье — буду рад сообщениям в личку.
Автор: Fesor