Мне тут попалась идеальная статья про DI в который нашелся очень интересный пример для разбора. Есть фундаментальные основы, которые почему то никто не хочет сформулировать, а начинающим разработчикам, которые впервые сталкиваются с концепцией DI, в первую очередь надо бы рассказать эти фундаментальные основы, но почему то нет желающих это сделать и у меня даже есть предположение, почему это не получается, я попробую их как-то выразить в том числе.
Я знаю что искать ошибки в статьях начинающих на Хабре это плохой тон, и я вряд ли выйду в плюс с такой статьей, но как говорится: Платон мне друг, но истина дороже.
В предыдущей статье мы выяснили как создать два класса (Хост и Енкодер, класс А и класс В) один из которых (А) не может работать без использования функций другого класса (В, а может, и без данных из этого класса В не может работать), но при этом совершенно не зависит от этого класса В! То есть класс А может запросто работать с любым другим классом (C, D, … ) вместо класса В, при некотором условии изложенном в предыдущей статье. По моему, та статья может быть хорошей разминкой для понимания концепции Внедрения Зависимостей. И опрределенно эта статья может считаться продолжением темы практической архитектуры ПО.
На самом деле если вас интересует техника с внедрением зависимостей или вам приходится разбираться с кодом в котором используется эта техника вы вряд ли такой уж начинающий программист.
Собственно есть две причины начать разбираться с этой концепцией, первая — это когда тебе кто-то рассказал или ты где то прочитал про это и решил разобраться поглубже, вторая — это когда дали код который работает под фреймворком DI и ты с удивлением обнаружил что там нет кода который создает классы, нет ни одной строчки кода в которой присутствует оператор new()! Как же так? К сожалению у меня нет под рукой такой системы, поэтому я излагаю по памяти и могу не точно воспроизводить синтаксис, но надеюсь, понятно изложить базовые фундаментальные идеи, но это ближе к концу.
Как известно все гениальное просто и идеи которые лежат в основе DI достаточно просты для тех кто не отрицает концепцию объединения связанных данных и методов для работы с этими данными в одном объекте тип которого определяется ключевым словом класс.
Я согласен с автором исходной статьи: в каком то смысле DI это всегда практическая реализация принципов SOLID.
Для начала я перепишу примеры из той действительно хорошей, по моему, статьи на привычном мне Си-подобном (близком к C#, указатели и хидера нам не понадобятся) синтаксисе с копией некоторых комментариев из той статьи, они нам очень пригодятся.
Итак нам предлагается в качестве базовых классов для реализации:
- Модель данных - CartItem
. Описывает товар в корзине и управляет его количеством и стоимостью.
class CartItem
{
public readonly int id,
private int count,
private int price
public CartItem(
int id, int count, int price
) {this.id=id; this.count=count; this.price=price; }
public int getCost()
{
return this->price * this->count;
}
}
тут никаких претензий: простой класс для демонстрации идеи, он должен быть простой, иначе идею будет сложно отследить за сложностью классов для примера.
- Слой хранения - SimpleStorage
. Представляет простое хранилище данных корзины в сессии. Отвечает за сохранение и загрузку списка товаров.
class SimpleStorage
{
private readonly string key;
public SimpleStorage( string key
) {this.key = key;}
public CartItem[] load()
{
return SESSION.getOnlyCartItems (this->key);
}
public function save(array $data): void
{
…
//we don`t use it!
}
}
Тут мне кажется уже скрывается некоторое не понимание принципов предметной области, которая, на мой взгляд, вполне недвусмысленно обозначена — мы работаем с базой данных, что можно понять по ключевому слову Storage
, но мы создаем объекты клиента для работы клиента. SimpleStorage
— это абстракция к объектам базы данных, будь это реляционные таблицы или что-бы то ни было еще. Тут уже есть некоторая неточность, но она не фатальная, она не убьет наш проект если мы начнем строить архитектуру классов в этом нашем проекте таким образом.
- Слой логики расчёта - SimpleCalculator
. Отвечает за вычисление общей стоимости всех товаров в корзине.
class SimpleCalculator
{
public int getCost(CartItem[] items)
{
int cost = 0;
foreach (items as item)//это не C# синтаксис, но я специально оставил как было, чтобы продемонстрировать что разницы особо нет, понятно в любом случае
{
cost += item->getCost();
}
return $cost;
}
}
А вот и:
- Слой бизнес-логики - Cart
.
Теперь необходимо реализовать функционал. Как это можно решить быстро? 1. Получить список товаров и хранилища. 2. Рассчитать общую стоимость корзины.
class Cart
{
/** @var CartItem[] */
private array $items = [];
private bool $loaded = false;
public function getCost(): int
{
$this->loadItems();
return (new SimpleCalculator())->getCost($this->items);
}
private function loadItems(): void
{
if ($this->loaded) {
return;
}
$this->items = (new SimpleStorage('cart'))->load();
$this->loaded = true;
}
}
И что у нас получилось? Слои:
CartItem
| \
SimpleStorage --- SimpleCalculator
| /
Cart
В таком виде мы уже можем наблюдать не соответствие определенных автором этого каркаса слоев с уровнями на которых расположились наши классы.
А как вам, такая основа для построения программы, не вызывает у вас нехороших воспоминаний? Что-то про смертельный ромб? А может вы просто чувствуете, что здесь что-то не так? У меня сразу появилось такое чувство, про ромб я что-то слышал не очень внятно и не вдавался в детали. А здесь меня еще и отвлекали красивые, всем известные аббревиатуры DI, SOLID. В первую очередь я думал: «А что здесь не так именно с ними с DI и SOLID?» Но с ними здесь все более менее хорошо, хотя и нет базового понимания в каком-то смысле, которое в первую очередь и нужно начинающим, на которых, казалось бы, ориентирована статья, то есть оказалось что это все ложный след про модные аббревиатуры DI, SOLID.
А потом, как говорится: «Выпив, он трезво рассудил»! При ближайшем рассмотрении меня в первую очередь смутило что у нас есть уровень:
КартИтем
а потом, через уровень у нас появляется уровень
Карт
и я начал думать, а в чем собственно разница? Чем Карт отличается или должен отличаться от КартИтем? И почему это надо разделить на подуровни, да еще и не смежные подуровни? Вроде бы совершенно логично что КартИтем, вроде бы, это один из массива Карт-s, то есть множества Карт (карт во множественном числе)... Тут явно что-то не сходится. Начинаем наше расследование?
Но тут меня сразу и осенило что Карт (как тип-класс), очевидно, не нужен! Что это НЕ нужный уровень абстракции, потому что логика вычисления, которую автор назвал в полном соответствии с текущей модой, бизнес логикой (куда же нам без бизнеса, то есть со свиным рылом в калашный ряд?)!
Оказывается нет ни какой проблемы чтобы ту же бизнес логику (которая на самом деле логика вычислений) разместить в классе SimpleCalculator
кроме… Кроме того что пример для рассказа про DI получится уж совсем какой-то куцый! А так, вообще-то, калькулятор это и есть вычисления — и значит логика вычислений это и есть калькулятор!
Это не проблема того что этот уровень неправильно назвали! Я давно понял что названия ни на что по большому счету не влияют! Поэтому название SimpleStorage не является большой проблемой
Как говорит русская поговорка: «Хоть горшком назови только в печку не ставь!». То есть пока в печку не поставили — то что назвали горшком, но по факту горшком не является — проблемы нет.
То есть если вы что-то не правильно назвали, но используете, более менее, в соответствии с целевой функцией то проблем никаких нет, переименовать всегда можно — это ничего не стоит!
Интересно что в нашем классе Cart
мы можем найти интересное поле loaded
, но нет такого же поля calculated! Ну если вы догадались сэкономить на загрузке, почему тот же самый подход не использовать для, собственно, вычислений? Это странно, вы не находите? Как минимум не последовательно! Как будто мы дошли до определенного предела и отключили мозги… себе. Тут мы оптимизируем, а тут нам на все наср… наплевать! Но это плохое программирование, то есть это то, что мне кажется плохим программированием — вот здесь мы думаем, а здесь нам все равно. Хотя на самом деле мы отключили мозги после того как сосредоточились на попытке рассказать миру что такое DI, как будто все остальное для нас потеряло значение.
Кстати про интересное поле loaded
. Оно тоже явно размещено не в том классе, с этим полем связана логика обновления данных которые мы получаем с помощью объекта SimpleStorage
, а что если нам придется поменять эту логику и добавить например обновления списка данных по событиям которые, оказывается, приходят из базы данных.
В общем если бы меня спросили как правильно назвать SimpleStorage
, я бы порекомендовал чтобы вот в этом конкретном случае эта сущность называлась какой-то вариацией от VIEW, потому что это, в общем случае, именно отображение данных которые хранятся в базе данных в список объектов (массив) на клиенте, это данные, которые получили через интерфейс (запрос) они могут отличаться от того что действительно хранится в базе данных, это надо учитывать и про это надо помнить!
Но самая главная ошибка конечно не в этом, не в том что нам не хватает поля, то есть не в том что нам вообще чего-то не хватает! Главная проблема, по моему в том, что у нас оказалось совершенно лишним, то есть не нужным. Потому что то что нам не нужно, это то, что нам мешает, это такая заноза в заднице, которая всегда будет отравлять нам жизнь (всегда мешать) и которую мы будем холить и лелеять потому что любые действия с ней вызывают боль! Это то что в конце концов обрушит наш проект, потому что если с самого начала у нас в архитектуре заложена червоточина она сгноит и разложит(коррумпирует) все наши построения, упорные усилия и в конце концов разрушит наш проект!
Ошибка что мы создали класс Карт, когда нам нужен только класс Калькулятор! — лишний уровень абстракции, который не просто лишний он еще и создает путаницу потому что у нас есть уровень КартИтем который вроде бы то же самое, и соответственно у нас расщепляется логика работы с объектами Карт на два уровня. Естественно это приводит к путанице!
Архитектурная ошибка, это очень серьезная ошибка, а если вы базируете какие то свои объяснения на примере с архитектурной ошибкой вы закладываете мину не только под свои будущие решения, это такая медвежья услуга для всех кто решит руководствоваться вашими пояснениями, и особенно выводами из ваших объяснений.
Вот SESSION
которая почему то не заслужила у автора исходной статьи совершенно никакого упоминания или выделения для нее слоя (уровня), на мой взгляд, как раз заслуживает очень пристального внимания! По моему как раз SESSION
является очевидной абстракцией базы данных (хранилища данных, Storage
)
А класс SimpleStorage
я бы заменил, например, таким классом:
class CartsView
{
private readonly string key;
public SimpleStorage( string key )
{this.key = key; load();}
private CartItem[] items;
private bool loaded = false;
public void load()
{
if (this->loaded) {
return;
}
this->items = SESSION.getOnlyCartItems (this->key);
this->loaded = true;
return
}
public function save(array $data): void
{
…
//we don`t use it!
}
}
У вас наверняка возникнет вопрос: «А откуда у нас берется объект SESSION
?». Вариантов на самом деле не много, это или статический класс, или глобальный (статический) объект созданный при создании приложения как синглтон.
Калькулятор тоже придется немного поправить потому что это калькулятор должен принимать как зависимость ссылку на объект CartsView
:
class SimpleCalculator
{
public int getCost( CartsView view)
{
int cost = 0;
foreach (var item in view)//это значит нам не хватает интерфейса итераторов в предыдущем классе!
{
cost += item->getCost();
}
return cost;
}
}
Обратите внимание что мы напрямую используем наш объект представления (view) чтобы итерироваться по списку, который это вью как раз инкапсулирует, мы обернули наш ранее бесхозный список-массив в объект, в котором можем спрятать всю специальную работу не только по его временному хранению после запроса из базы данных, но и возможные обновления по событиям из базы данных. И класс CartsView придется доработать, соответственно:
class CartsView
{
private readonly string key;
public SimpleStorage( string key
) {this.key = key; load();}
private CartItem[] items;
private bool loaded = false;
public void load()
{
if (this->loaded) {
return;
}
this->items = SESSION.getOnlyCartItems (this->key);
this->loaded = true;
return
}
public function save(array $data): void
{
…
//we don`t use it!
}
public IEnumerator<CartItem> GetEnumerator()
{
load();
foreach (CartItem item in items)
{
yield return item;
}
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator(); // Call the generic GetEnumerator()
}
}
Начальный пример это не просто некоторая выполненная работа в которой есть ошибки, это работа в неправильном направлении, которая никуда не ведет или ведет к пониманию что вы все это время делали что-то не то и вам просто напросто надо все переделать, то есть начать сначала. А это даже психологически очень не просто, осознать что все усилия потрачены зря и вот таким способом ничего не получится и надо перестраивать не только синтаксис или какую-то локальную логику, надо перестраивать свое восприятие совокупности задач и по новой искать совокупность решений этих задач и какую-то базовую идею всех этих решений. Это очень тяжело психологически, особенно когда у вас еще нет каких-то особых достижений в этом упражнении. Это грустно и печально и не всем удается с этим справиться и продолжать искать путь вперед откатившись далеко назад.
Основы DI
Но я чувствую себя обязанным рассказать что же я считаю фундаментальными основами концепции DI. Как ни странно в статье на которую я ссылался все более менее написано по делу с точки зрения понимания реализации DI. Но чтобы заниматься реализацией неплохо для начала познакомить читателей с тем, а как мы дошли до использования этой концепции, как понять что она вам нужна в каком-то конкретном случае, в чем ее сильные и слабые стороны.
В самом общем случае зависимость (без всякого ее внедрения) - это зависимость одного типа А от определения другого типа B, то есть если у вас при определении вашего класса A (как типа каких-то объектов) используется где-то внутри этого определения другой класс B, вы не можете скомпилировать-слинковать код для вашего класса А не включив в компиляцию код класса B. Но в интерпретируемых языках это одновременно и проще и сложнее! Это во первых зависит от конкретного интерпретатора, а во вторых эта зависимость проявляется только на этапе инстанциирования объекта и его методов, структуры внутренних данных... уже при выполнении кода интерпретатором. А как это происходит для интерпретируемых языков разбираться очень сложно — там нет хоть какой то унифицирующей базы для рассмотрения этой процедуры теоретически.
Внедрение зависимостей рассматривает частный случай разрешения такой зависимости между классами для компилируемых языков, которую тем не менее можно адаптировать, в определенном смысле, и для интерпретируемых языков. Ограниченность частного случая заключается в том что зависимый класс А не просто использует класс B внутри своего определения (например как параметр своего метода или даже как локальную переменную в каком-то своем методе), а (чаще всего) нуждается в уже созданном объекте класса В для своей работы и для того чтобы самому быть созданным. Обычно под DI имеется ввиду четко очерченный круг зависимостей между классами (типами), и чаще всего это именно зависимости по возможности создания, которые, намеренно задаются через конструктор класса. Для примера:
class A
{
B depObj;
public A(B depObj)
{
this.depObj = depObj;
this.depObj.DoSomeWorkForA();
}
}
Такой пример наглядно показывает как зависимость классов через параметры методов (в данном случае через параметр конструктора) превращается-углубляется до зависимости между объектами: объект класса А нельзя создать не создав объект класса В потому что если объект В будет, например, равен NULL, мы просто получим эксепшен. Поэтому код создания объекта А в самом простом случае должен выглядеть так:
…
B depObj = new B();
A target = new A(depObj);
…
Но дальше возникает идея некоторого DI фреймворка или расширения компилятора! Дело в том что такой код создания необходимых объектов для создания других объектов может сгенерировать компилятор, если ввести определенную нотацию атрибутов (например) для классов и их конструкторов и помечать все классы и их методы которые подлежат такому автоматическому созданию. Это
1. с одной стороны должно облегчить жизнь разработчика так как его избавляют от необходимости писать вручную код генерации
2. должно заставить разработчика внимательнее относиться к зависимостям и передавать их должным образом (например только через конструктор)
3. это дает возможность анализировать зависимости и сообщать об ошибках этих зависимостей, так как если в них есть ошибки компилятор просто не сможет сгенерировать код создания объектов.
Но как известно у монеты всегда есть 2-я сторона, очевидны и слабые стороны такого подхода:
Разработчик в определенной степени теряет контроль над кодом который генерируется;
Разработчик должен потратить время на изучение системы с генерацией кода, изучить и держать в голове новые вариации синтаксиса, знать какие-то нюансы, в реализации фреймворка/расширения компилятора возможны ошибки…
Разработчик оказывается в некоторой степени ограничен в применении разных видов зависимостей между классами.
На этом пока все, дальше я не решаюсь писать не получив обратной связи, потому что не надеюсь на благосклонность аудитории по такой сложной и для многих (я подозреваю) противоречивой теме.
Dhwtj
Не читая, навскидку:
Ошибки выделения абстракций в ООП из-за того, что ООП инструменты не достаточно абстрактны. Они заточены под mutable entity с их скрытым id (уникальностью).
Например, обеспечение взаимодействия 2 объектов. Это не зона ответственности одного из них. Значит, в ООП добавим класс "чистая выдумка", избавляться от уникальности скажем через static class. А в ФП просто добавим функцию.
То есть в ООП нужны ментальные усилия чтобы перейти к абстракциям