Проектирование и рефакторинг: по стопам Эвклида

в 20:31, , рубрики: .net, architecture design, C#, design patterns, oop patterns, refactoring, solid, ооп, Проектирование и рефакторинг, Совершенный код, метки:

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

Итак, кому же? В первую очередь, наверное, таким же как я — новичкам в области проектирования программных систем. Тем, кто не обладает колоссальным эмпирическим опытом и владеет шаблонами проектирования исключительно на основании общих рассуждений. Ещё более эффективным будет прочтение такой статьи тем, кто ни разу не слышал про SOLID, GRASP и прочие принципы проектирования. Ибо я искренне уповаю на то, что мне удастся показать, как из базовых теоретических суждений на основании законов логики выводятся все те непоколебимые постулаты, ранее казавшиеся a priori истинными.

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

Предисловие

Почему же стал на такой путь и пишу о столь фундаментальной, скорее всего всеми давно понятой, теме?

Несколько причин.

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

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

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

Кто же я такой? Меня зовут Джош, я из Харькова, мне 22, и я всё ещё Junior Software Developer. Наверное. Примерно год назад я уже публиковался на хабре, и на тот момент мои размышления на тему компонентно-ориентированного движка на C# были встречены не так плохо, как ожидал. Более того, публикация вырвалась из песочницы и какое-то время набирала просмотры. Но это так, знакомства ради, с которым я и без того затянул.

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

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

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

Что же, надеюсь, я не утомил читателя столь долгим введением, и, пожалуй, приступим.

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

Структура

Метафора 1. Иерархично всё. Вселенную можно рассматривать как набор уровней различной степени приближения: кварки, атомы, молекулы, вещества, клетки, ткани… Не секрет, что само человеческое мышление принимает иерархичную форму, когда модули нижнего уровня складывают модули верхнего уровня, а потому вопрос о том, иерархична ли Вселенная или, всё-таки, человек, я оставлю в качестве философского упражнения пытливому читателю, ибо подобные размышления не касаются основной линии сюжета.

Понятие 1.1. Задача — требование к функциональности приложения.
Понятие 1.2. Блок — код, сосредоточенный вокруг выполнения одной и только одной задачи.
Понятие 1.3. Зависимость — использование одним блоком кода другого.
Понятие 1.4. Степень приближения — количество уровней, на которые необходимо подняться от атомарного, чтобы достигнуть данного.
Понятие 1.5. Абстракция — блок, не имеющий определённой реализации на этапе компиляции.

Функция 1.1. Apr(x) — степень приближения x.
Функция 1.2. Qd(х) — количество зависимостей блока х.

Положение 1.1. С течением времени количество блоков, из которых состоит программная система, увеличивается.
Положение 1.2. Атомарным для программной системы является уровень базовых операторов и ключевых слов.
Положение 1.3. Чем выше степень приближения абстракции, т.е. чем более общую задачу она призвана решать, тем меньше вероятность того, что появятся изменения.
Положение 1.3.1. Зависимость от абстракции имеет меньшую вероятность привести к косвенным изменениям.
Положение 1.3.1.1. Абстракции понижают энтропию.
Положение 1.4. Избыточность порождает изменения.

Процессы

Понятие 2.1. Создание — увеличение количества блоков в приложении путём написания нового кода.
Понятие 2.2. Изменение — отображение изменения формулировки задачи на блоки.
Понятие 2.3. Косвенное изменение — отображение изменения блока на зависимые от оного.
Понятие 2.4. Корректность — количественная характеристика проверки. Показывает, насколько точно и полно работает блок относительно выдвинутых пред- и постусловий.
Понятие 2.5. Энтропия — количественная характеристика качества кода, показывающая, сколько дополнительного бюджета потребуется на внедрение нового функционала. Выражается через отношение между средним временем изменения и средним временем создания блоков.

Функция 2.1. Tc(х, y) — время создания блока х в рамках задачи y.
Функция 2.2. Tu(х, y) — время изменения блока х в рамках задачи y.
Функция 2.3. Qu(х, у) — кол-во изменений блока х в рамках задачи у.
Функция 2.4. Qm(х, у) — кол-во кос. изменений блока х в рамках задачи у.
Функция 2.5. Md(x) — отображение из множества косвенных изменений блока x в множество тех, что приведут к реальным.
Функция 2.6. Cor(x) — показывает степень корректности блока x, т.е. отношение между теоретическим результатом и фактическим. Можно формально определить как отношение количества элементов множества, формирующегося путём пересечения результатов работы ожидаемой функции с фактической, к количеству элементов множества результатов работы ожидаемой функции.
Функция 2.7. Ku(х) — коэффициент хрупкости блока х. Отношение между количеством Md(х) к количеству косвенных изменений x.

Положение 2.1. Новый код увеличивает энтропию.
Положение 2.2. Изменения увеличивают энтропию.
Положение 2.3. Косвенные изменения косвенно снижают корректность.
Положение 2.3.1. Косвенные изменения могут привести к не косвенным.
Положение 2.3.1.1. Косвенные изменения косвенно увеличивают энтропию.
Положение 2.3.2. Проверочные блоки снижают степень влияния косвенных изменений на корректность.

Организация

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

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

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

SOLID

Напоследок я хочу взять на себя смелость теоретически обосновать применение наиболее популярных принципов — SOLID.

Single Responsibility Principle — принцип единой ответственности. Формально звучит так: программный модуль или класс должен иметь только ответственность только за одну функциональную часть, предоставляемую приложением. У него должна быть “только одна причина для изменения” (Роберт Мартин). В нашей теоретической модели это напрямую вытекает из определения зависимости: как уже было показано выше, состояние, когда логически один блок верхнего уровня содержит два блока нижнего уровня, выполняющих разные задачи, но зависящие друг от друга, имеет большую энтропию, чем состояние без циклической зависимости.

Open/Closed Principle — принцип открытости/закрытости. Кратко: изменение поведения сущности должно производиться не за счёт модификации её исходного кода, а за счёт расширения, под которым подразумеваются специфичные механизмы вроде наследования, полиморфизма и абстракций. В свете вышеизложенных построений можно сказать, что время внедрения новой функциональности составляет только время создания и никак не время изменения, что, таким образом значительно уменьшает энтропию.

Liskov Substitution Principle — принцип подстановки Барбары Лисков. Говорит о том, что должна существовать возможность заменить все объекты типа T на объекты типа S, где S — подтип T, без ущерба корректности и работоспособности программы. На формальном языке это можно выразить так: пусть функция f(x) справедлива для всех x типа T, тогда функция f(y) должна быть также справедливой для всех y типа S, где S — подтип T. Данный принцип является следствием стремления уменьшить коэффициент хрупкости приложения и, к сожалению, не даёт никаких конкретных рекомендаций, а лишь постулирует требование к программной системе.

Interface Segregation Principle — принцип разделения интерфейсов. Объявляет, что большое количество мелких интерфейсов лучше, чем один большой, т.к. клиенты, зависящие от интерфейсов, могут пользоваться только той их частью, которая им нужна.
Могу заметить, что такой принцип является продолжением SRP, прикладываемым на область абстракций. Аргументы и доказательства абсолютно те же.

Dependency Inversion Principle — принцип инверсии зависимостей. Для меня один из самых труднопонимаемых принципов, гласящий, что объекты высокого уровня не должны зависеть от объектов низкого уровня, и наоборот — оба уровня должны зависеть от абстракций. Логичность и истинность данного принципа напрямую следует из Положения 1.3.1.1: вероятность изменения абстракции ниже, чем вероятность изменения конкретного блока-реализатора.

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

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

Практика

В качестве упражнения рассмотрим реальный пример и попытаемся количественно проанализировать его структуру.

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

public class DamageMediator : GameComponent
{
  public int Next()
  {
    var equipment = GetEquipment();
    var stats = GetStats();

    var weapon = equipment.Weapon;

    var damage = weapon.Damage;
    var isCrit = stats.CriticalChance.Next();

    var result = isCrit ? damage.Next() + damage.Next() : damage.Next();

    return result;
  }
}

Посчитаем количество зависимостей.

Блоки-функции: GetEquipment, GetStats, Equipment.Weapon, Weapon.Damage, Stats.CriticalChance, CriticalChance.Next, Damage.Next.
Блоки-типы: Equipment, Stats, Weapon, Damage, Chance.
Таким образом Qd = 12.

Наша задача заключается в том, чтобы минимизировать это значение, снизив тем самым энтропию.

1. Избавимся от GetEquipment и GetStats, перенеся их в параметры.

public class DamageMediator : GameComponent
{
  public int Next(Equipment equipment, Stats stats)
  {
     var weapon = equipment.Weapon;

     var damage = weapon.Damage;
     var isCrit = stats.CriticalChance.Next();

     var result = isCrit ? damage.Next() + damage.Next() : damage.Next();

     return result;
  }
}

2. Заменим Equipment на Weapon.

public class DamageMediator : GameComponent
{
  public int Next(Weapon weapon, Stats stats)
  {
     var damage = weapon.Damage;
     var isCrit = stats.CriticalChance.Next();

     var result = isCrit ? damage.Next() + damage.Next() : damage.Next();

     return result;
  }
}

3. Заменим Weapon на Damage.

public class DamageMediator : GameComponent
{
  public int Next(Damage damage, Stats stats)
  {
     var isCrit = stats.CriticalChance.Next();

     var result = isCrit ? damage.Next() + damage.Next() : damage.Next();

     return result;
  }
}

4. Заменим Stats на Chance.

public class DamageMediator : GameComponent
{
  public int Next(Damage damage, Chance criticalChance)
  {
     var isCrit = criticalChance.Next();
     
     return isCrit ? damage.Next() + damage.Next() : damage.Next();
  }
}

Таким образом теперь состояние блока: Qd = 4.

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

Во-первых, уменьшая количество зависимостей, мы временно избавились от двух, казалось бы, непримечательных методов: GetEquipment и GetStats. Тем лучше для текущего примера — можно будет более детально рассмотреть возникающие проблемы. Оказывается, данные методы получали экземпляры экипировки и характеристик персонажа, используя систему компонентов: сам DamageMediator является GameComponent и по соглашению имеет доступ к ссылке на родительский GameComponentContainer (прошу меня простить, что приходится это выслушивать, но в моей первой статье есть разъяснения), соответственно, первоначальная версия кода предполагала, что компоненты экипировки и характеристик будут также находиться в контейнере.

Во-вторых, на самом деле просчёт урона не ограничивается броском кости на критический удар. Что, если теперь появилось (на самом деле, было) условие: на окончательное значение будет влиять одна из базовых характеристик, например, сила или ловкость, причём это влияние будет определяться особенностью оружия?

Справляемся с неудобствами

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

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

Полагаю, в промежутке следует уделить несколько минут разъяснению грядущих изменений, ибо пытливый читатель, вероятнее всего, задаётся вопросом: “А к чему, собственно, это разделение? Всё и без того работает”.

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

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

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

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

Дабы сгладить проблему, вернём старую версию метода Next, не удаляя новую. В старой версии оставим только ту часть работы, которая ответственна за взаимодействие с иерархией компонентов. Таким образом получается нечто такого плана:

public class DamageMediator : GameComponent
{
  // Возвращаем первоначальное API.
  public int Next()
  {
    // Вернули два старых метода.
    var equipment = GetEquipment();
    var stats = GetStats();

    return Next(equipment.Weapon.Damage, stats.CriticalChance);
  }

  private static int Next(Damage damage, Chance criticalChance)
  {
     var isCrit = criticalChance.Next();
     
     return isCrit ? damage.Next() + damage.Next() : damage.Next();
  }
}

Что же, в конце концов, поменялось? Стало быть, энтропия, потому как количество зависимостей уменьшилось: если ранее существовала циклическая зависимость между блоком просчёта урона и блоком получения компонентов, сейчас осталась лишь зависимость от блока получения компонентов к блоку расчёта урона. Кроме того, уменьшилась вероятность изменения блока просчёта урона, т.к. его количество зависимостей, как помните, составляет Qd = 4.

Очевидно, что анализ структуры блоков и попытка уменьшить количество зависимостей приводят к уменьшению энтропии, если исходить из первоначальных теоретических зарисовок.

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

public class DamageMediator : GameComponent
{
  public int Next()
  {
    var equipment = GetEquipment();
    var stats = GetStats();

    return DamageUtil.Next(equipment.Weapon.Damage, stats.CriticalChance);
  }
}

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

Итоги

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

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

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

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

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

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

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

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

Всем спасибо за внимание и до скорых встреч!

Автор: JoshuaLight

Источник

Поделиться новостью

* - обязательные к заполнению поля