Однажды я увидел типичный код вроде:

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

Более того, даже факт «наверное, что-то было не так» взят из пустой коллекции Volatilities. Что также может быть правильным в некоторых случаях.

Я уверен, что большинство опытных разработчиков видели такие строки кода, в которых есть тайные знания. Например, это может быть так: «если эти флаги имеют следующую комбинацию, то мы должны сделать A, B и, возможно, C, но точно не D» (конечно, вы не могли видеть это из целевой модели).

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

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

Однако если вы работаете с долгоиграющей системой, то эта статья для вас.

Используйте шаблон посетителя

Часто одно и то же поле класса имеет ссылку на объект, который имеет разную семантику (как во вступительном примере). Однако, чтобы уменьшить количество классов, разработчик часто оставляет только один тип с множеством флагов с такими идеями, как «если массив равен нулю, значит, система полностью сломана, если массив пуст, значит, система прогревается». Такое поведение помогает отправителю данных скрыть настоящую ошибку (что плохо для проекта, однако может быть полезно для недобросовестных). Однако есть правильная схема, которая помогает получателю понять, что там произошло. Название этой модели — посетители.

В этом случае пример из шапки статьи можно настроить на:

Используя этот шаблон:

  • Нам нужно больше кода. К сожалению, если мы хотим добавить в модель больше информации, она должна вырасти.
  • Из-за наследования мы не можем сериализовать Response в json/protobuf, так как они теряют информацию о типе. Для этого нам нужно создать специальный контейнер (например, это может быть класс, у которого есть отдельные поля для каждой реализации IVolatilityResponse, однако должно быть заполнено только одно из них).
  • Расширение модели (например, добавление новой реализации IVolatilityResponse) требует расширения IVolatilityResponseVisitor‹TInput, TOutput› путем добавления новых методов. Это означает, что компилятор требует реализации для всех из них. Так что никто не забывает поддерживать новые типы.
  • Из-за статической типизации мы можем избежать постоянного обновления документации с упоминанием новых полей. Все возможные сценарии покрываются нашей моделью в коде. Более того, его может понять и человек, и компилятор. У вас не могло быть несоответствия между кодом и документацией, потому что вы можете просто не создать последнюю и показать все детали в модели.

Ограничения наследования на других языках

Некоторые языки программирования (например — Scala или Kotlin) имеют специальные ключевые слова, которые помогают запретить наследование от вашего типа вне библиотеки/пакета.

Например, приведенный выше пример можно переписать на Kotlin:

Как видите, мы получаем меньше кода, чем в C#/Java выше. Однако в том же примере с C# мы знаем, что все потомки VolatilityResponse находятся в одном файле, поэтому приведенный ниже код не компилируется (поскольку мы не проверяли все возможные случаи).

Однако эта проверка работает только для функционального стиля кодирования. Код ниже скомпилирован без ошибок:

Примитивные типы могут иметь разную семантику

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

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

Правильный ответ — нет, потому что мы пытаемся сравнить id пользователя и id группы (в первом примере). А во втором примере есть ошибка, когда мы устанавливаем id для Group на id для User.

Однако это можно исправить простым изменением кода: просто создайте разные типы для разных идентификаторов: GroupId и UserId в нашем случае. И тогда вы не сможете создать Пользователя с идентификатором Группы, потому что они имеют разные типы.

Более того, нельзя было смешивать параметры в методах с одинаковыми, потому что теперь они разные:

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

И мы можем сделать это с помощью следующей схемы:

Ну наконец то:

  • Опять же, нам нужно больше кода, потому что мы хотим предоставить больше информации для компилятора. А это значит больше символов в файлах.
  • Мы создали новые типы (об оптимизациях мы поговорим ниже), которые иногда могут снижать производительность.
  • В нашем коде:
  1. Мы не могли смешивать идентификаторы. Компилятор, Intelli Sence и человек явно видят, что поле с типом UserId принимает только экземпляры UserId, а не int.
  2. Мы запретили сравнивать несравнимое. Однако хочу заметить, что код равенства не доработан — лучше реализовать интерфейс IEquitable, метод GetHashCode и т. д. Так что лучше не копировать пример вставки из этой статьи. Однако идея ясна: мы отказываемся от выражений с разнотипным сравнением. Например. вместо вопроса «Являются ли эти фрукты одинаковыми?» компилятор работает с «Являются ли груша и яблоко одним и тем же?» (если конечно это странно).

Подробнее о Sql и ограничениях

Часто в вашем приложении есть правила, которые можно просто проверить. В худшем случае набор функций может быть таким:

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

  • Мы не объясняем разработчику (например, пользователю API), что нам там нужно.
  • Мы можем забыть применять одни и те же проверки одним и тем же методом в проекте. Итак, нам нужно скопировать и вставить эту строку (или метод извлечения).
  • Когда мы получаем String, что означает Name, мы не теряем сознание сразу. Мы продолжаем выполнение, чтобы получить исключение после пары инструкций процессора.

Правильное поведение:

  • Создайте отдельный тип (в нашем случае это Name).
  • Добавьте все проверки в конструктор.
  • Оберните все String в Name как можно скорее, чтобы раньше получать ошибки.

И, наконец, мы получаем следующие достижения:

  • Меньше функционального кода, потому что мы извлекли все проверки в конструктор Name.
  • Достижение стратегии Fail fast. Теперь мы терпит неудачу сразу после получения проблемного имени, вместо того, чтобы вызывать другие методы, которые дают нам ту же ошибку. Более того, вместо ошибок ограничения базы данных мы объяснили ошибку тем, что нет никаких причин для обработки таких длинных имен.
  • И то же самое с примером идентификатора выше, нам сложнее смешивать аргументы функции. Теперь у нас есть функция void UpdateData(Name name, Email email, PhoneNumber number) с тремя разными типами (вместо трех string).

Еще немного о преобразовании типов

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

  • Добавить реализацию интерфейса interface IValueGet‹TValue›{ TValue Wrapped { get; }. В этом случае на уровне перевода Sql мы можем получить значения непосредственно из оболочки любого примитивного типа.
  • Вместо создания множества одинаковых типов в коде мы можем создать абстрактный базовый класс. Тогда мы сможем наследовать от него все остальное. В итоге получаем такой код:

Производительность

При разговоре о скорости работы приложения (по поводу большого количества оберток) можно привести два диалектических аргумента:

  • Больше типов и более высокая иерархия классов приводят к увеличению размера кода промежуточного языка. И тогда у компилятора Just In Time возникают трудности с оптимизацией приложения. Таким образом, такая сильная стратегия набора текста идет в паре с серьезной медлительностью приложений.
  • Если у вас много оберток, то все они съедают память. Таким образом, любая дополнительная оболочка резко увеличивает требования к оперативной памяти приложения.

Честно говоря, оба аргумента обычно используются без каких-либо фактов, однако:

  • Большинство приложений (на Java/.Net) тратят память компьютера на String и массивы байтов (по моим измерениям: ~80–95% от общего объема затрат на них). Таким образом, использование оберток невидимо по сравнению с объемом хранимого текста/двоичного файла. Однако обертки типов получают важное преимущество: мы можем понять, какие бизнес-типы имеют наибольшее влияние на память приложения. Вы не ощущаете свою программную память как список смазанных строк. Но точно разные строго типизированные обертки. Так в общем поможет понять, кто много памяти жрет.
  • Аргумент оптимизации JIT кажется истинным, однако он не полный. Строгая типизация помогает вашему приложению убрать множество проверок при входе в функции. Все ваши модели проверяются при постройке. Таким образом, в общем случае в вашем коде меньше проверок и охранников (вы можете просто задать правильный тип в аргументах функции). Кроме того, из-за извлечения кода проверки в конструкторы вы можете проще понять, если они занимают много времени.
  • К сожалению, я не смог показать комплексный бенчмарк, который сравнивает большой проект с большим количеством микротипов и то же самое с классической разработкой, оперирующей непосредственно с int, string и другими примитивными типами. Основная причина: мне нужно иметь общедоступный типичный огромный проект для тестирования. Дополнительно мне нужно обоснование, что именно этот проект действительно типичен. И последний пункт действительно сложный, потому что реальные проекты абсолютно не такие. И я хотел бы избежать синтетических тестов здесь, потому что (только по моим измерениям) огромные корпоративные приложения почти ничего не тратят на создание таких типов (например, снижение производительности ниже ошибки измерения).

Как оптимизировать код с большим количеством микротипов

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

Тем не менее, ускорение производительности приближается:

  • Используйте типы значений вместо ссылочных. Это может быть более полезно, если ваш Wrapper уже работает со значениями типов, поэтому большая часть информации может быть передана с использованием стека. Однако помните, что прирост производительности будет только в том случае, если ваш код имеет реальную проблему с частым сборщиком мусора, именно из-за микротипов.
  1. struct в .Net может быть причиной частой упаковки и распаковки. И все экземпляры типов, где эта обертка является полем, съедают больше оперативной памяти. Кроме того, сбор таких оберток также съедает больше памяти (потому что все внутренние массивы со значениями, а не только со ссылками).
  2. Тип inline из Kotlin/Scala очень ограничен. Например, вы не можете хранить в них несколько полей (что может быть весьма полезно для кэширования ToString/GetHashCode).
  • Некоторые JIT-оптимизаторы могут по возможности выделять память в стеке. Например, .Net делает это для небольших временных объектов. GraalVM из Java умеет размещать объект в стеке, однако копирует поля в кучу, если экземпляр должен быть возвращен из функции (что весьма полезно для кода с большим количеством if).
  • Используйте интернирование объектов (например, попробуйте повторно использовать ранее созданные объекты только для чтения).
  1. Если конструктор имеет только один аргумент, вы можете создать кеш, который имеет этот аргумент в качестве ключа. Таким образом, вместо создания нового экземпляра вы можете просто вернуться к ранее подготовленному.
  2. Если конструктор имеет несколько аргументов, вы можете просто создать новый объект, однако затем вы можете проверить, отсутствует ли он в кеше. Если там есть такой же можно вернуть более старый.
  3. Такой подход замедляет построение объекта, поскольку требует глубокого выполнения Equals/GetHashCode. Однако он получает прирост производительности для следующих проверок на равенство, потому что большинство одинаковых объектов имеют одну и ту же ссылку. И у большинства разных объектов разный хеш-код.
  4. Таким образом, эта оптимизация увеличивает скорость приложения (теоретически) из-за более быстрого GetHashCode/Equals. Плюс уменьшается время жизни объекта, потому что большинство новых объектов отбрасываются после проверки кеша.
  • Проверить входные параметры нового объекта, но не исправлять их. Несмотря на философские разглагольствования, этот совет повышает производительность приложения. Например, если вашему объекту требуется строка с ЗАГЛАВНЫМИ БУКВАМИ, у вас может быть как минимум два варианта: выполнить ToUpperInvariant аргумента, в противном случае вы можете проверить, что каждый символ из входной строки это капитал. Первый подход приводит к созданию новой строки. Второй создает только итератор в памяти (однако JIT также может оптимизировать это). Суммарно вы выделяете память в обоих случаях, однако вторая схема требует меньше.

Заключение

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

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

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

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

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