Типизируйте уже наконец свой код

в 6:40, , рубрики: .net, enterprise, java, perfect code, visitor, большой проект, визитор, долгоиграющее удовольствия, долгоиграющий код, моделирование, ооп, Проектирование и рефакторинг, расширяемый код, Совершенный код, строгая типизизация, типы, усложнение кода

Привет!

На днях мне в очередной раз на глаза попал код вида

if(someParameter.Volatilities.IsEmpty())
{
    // We have to report about the broken channels, however we could not differ it from just not started cold system.
    // Therefore write this case into the logs and then in case of emergency IT Ops will able to gather the target line
    Log.Info("Channel {0} is broken or was not started yet", someParameter.Key)
}

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

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

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

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

Однако если же вы занимаетесь долгоиграющим — добро пожаловать под кат.

Используйте паттерн visitor

Зачастую одно и то же поле содержит объект, который может иметь разные смысловые значения (как в примере). Однако для экономии классов разработчик оставляет только один тип, снабжая его флагами (или комментариями в стиле "если тут ничего нет, значит ничего не подсчиталось"). Подобный подход может замаскировать ошибку (что плохо для проекта, однако удобно для команды, которая поставляет сервис, ведь баги-то не видны снаружи). Более корректный вариант, который позволяет даже на дальнем конце провода узнать, что происходит на самом деле — это использовать интерфейс + визиторы.

В этом случае пример из заголовка превращаяется в код вида:

class Response
{
     public IVolatilityResponse Data { get; }
}

interface IVolatilityResponse
{
    TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input)
}

class VolatilityValues : IVolatilityResponse 
{
    public Surface Data;

    TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input)
    => visitor.Visit(this, input);
}

class CalculationIsBroken : IVolatilityResponse 
{
    TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input)
    => visitor.Visit(this, input);
}

interface IVolatilityResponseVisitor<TInput, TOutput>
{
    TOutput Visit(VolatilityValues instance, TInput input);

    TOutput Visit(CalculationIsBroken instance, TInput input);
}

При подобного рода обработке:

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

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

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

В частности, пример выше можно переписать на Kotlin так:

class Response (
     val data: IVolatilityResponse
)

sealed class VolatilityResponse

class VolatilityValues : VolatilityResponse()
{
    val data: Surface
}

class CalculationIsBroken : VolatilityResponse()

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

fun getResponseString(response: VolatilityResponse) = when(response) {
    is VolatilityValues -> data.toString()
}

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

fun getResponseString(response: VolatilityResponse) {
    when(response) {
        is VolatilityValues -> println(data.toString())
    }
}

Не все примитивные типы означают одно и то же

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

class Group
{
    public int Id { get; }

    public string Name { get; }
}

class User
{
    public int Id { get; }

    public int GroupId { get; }

    public string Name { get; }
}

Вроде стандартный код. Типы даже совпадают с аналогичными в базе данных. Однако вопрос: корректен ли код ниже?

public bool IsInGroup(User user, Group group)
{
    return user.Id == group.Id;
}

public User CreateUser(string name, Group group)
{
    return new User
    {
        Id = group.Id,
        GroupId = group.Id,
        name = name
    }
}

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

Как ни странно, это довольно просто исправить: просто заведите типы GroupId, UserId и так далее. Таким образом создание User работать уже не будет, так как у вас не сойдутся типы. Что невероятно классно, ведь вы смогли рассказать о модели компилятору.

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

public void SetUserGroup(UserId userId, GroupId groupId)
{
    /* some sql code */
}

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

И сделать это можно следующим образом:

class GroupId
{
    public int Id { get; }

    public bool Equals(GroupId groupId) => Id == groupId?.Id;

    [Obsolete("GroupId can be equal only with GroupId", error: true)]
    public override bool Equals(object obj) => Equals(obj as GroupId)

    public static bool operator==(GroupId id1, GroupId id2) 
    {
        if(ReferenceEquals(id1, id2))
            return true;

        if(ReferenceEquals(id1, null) || ReferenceEquals(id2, null))
            return false;

        return id1.Id == id2.Id;
    }

    [Obsolete("GroupId can be equal only with GroupId", error: true)]
    public static bool operator==(object _, GroupId __) => throw new NotSupportedException("GroupId can be equal only with GroupId")

    [Obsolete("GroupId can be equal only with GroupId", error: true)]
    public static bool operator==(GroupId _, object __) => throw new NotSupportedException("GroupId can be equal only with GroupId")
}

Как результат:

  • Нам опять потребовалось больше кода. Увы, если вы хотите дать больше информации компилятору, вам зачастую надо написать больше строчек.
  • Мы создали новые типы (об оптимизациях поговорим ниже), что иногда может незначительно ухудшить производительность.
  • В нашем коде:
    • Мы запретили путать идентификаторы. И компилятор, и разработчик теперь явно видят, что нельзя в поле GroupId запихнуть поле UserId
    • Мы запретили сравнивать несравнимое. Сразу отмечу, что код сравнения не совсем дописан (желательно еще реализовать интерфейс IEquitable, надо также реализовать метод GetHashCode), так что пример не надо просто копировать в проект. Однако сама идея ясна: мы четко запретили компилятору выражения, когда сравниваются не те типы. Т.е. вместо выражения "а равны ли эти фрукты?" компилятор теперь видит "а равна ли груша яблоку?".

Немного еще об sql и об ограничениях

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

void SetName(string name)
{
    if(name == null || name.IsEmpty() || !name[0].IsLetter || !name[0].IsCapital || name.Length > MAX_NAME_COLUMN_LENGTH)
    {
        throw ....
    }

    /**/
}

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

  • Мы не объяснили программисту и компилятору, что мы здесь хотим.
  • В другой похожей функции надо будет перекопировать проверки.
  • Когда мы получили string, которая будет обозначать name, мы не упали сразу, а зачем-то продолжили выполнение, чтобы упасть на несколько инструкций процессора позже.

Корректное поведение:

  • Создать отдельный тип (в нашем случае, видимо, Name).
  • В нем сделать все необходимые валидации и проверки.
  • Как можно быстрее оборачивать string в Name, чтобы как можно быстрее получить ошибку.

Как результат мы получаем:

  • Меньше кода, так как проверки для name мы вынесли в конструктор.
  • Стратегию Fail Fast — теперь, получив проблематичное имя, мы упадем сразу, вместо того, чтобы вызвать еще пару методов, однако всё равно упасть. Более того, вместо ошибки из базы данных вида "тип слишком большой", мы сразу узнаем, что нет смысла даже начинать обрабатывать такие имена.
  • Нам уже сложнее перепутать аргументы, если сигнатура функции: void UpdateData(Name name, Email email, PhoneNumber number). Ведь теперь мы передаем не три одинаковых string, а три разных разных сущности.

Немного о приведении типов

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

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

interface IValueGet<TValue>
{
    TValue Wrapped { get; }
}

abstract class BaseWrapper : IValueGet<TValue> 
{
    protected BaseWrapper(TValue initialValue)
    {
        Wrapped = initialValue;
    }

    public TValue Wrapped { get; private set; }
}

sealed class Name : BaseWrapper<string>
{
    public Name(string value)
        :base(value)
        {
            /*no necessary validations*/
        }
}

sealed class UserId : BaseWrapper<int>
{
    public UserId(int id)
        :base(id)
    {
            /*no necessary validations*/
    }
}

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

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

  • Чем больше типов, вложенности и il кода, тем медленнее ПО, так как jit`у сложнее оптимизировать программу. Поэтому подобного рода строгая типизация приведет к серьезным тормозам в проекте.
  • Чем больше оберток, тем больше приложение кушает памяти. Поэтому добавление wrapper`ов серьезно увеличит требования к оперативной памяти.

Строго говоря, оба аргумента зачастую приводят без фактов, однако:

  • На деле в большинстве приложений на той же java основную память забирают строки (и массивы байт). То есть создание оберток вообще вряд ли будет заметко конечному пользователю. Однако за счет подобного рода типизации мы получаем важный плюс: при анализе дампа памяти можно оценить, какой вклад в память дает каждый из ваших типов. Ведь вы видите не просто обезличенный список строк, размазанных по проекту. Наоборот — мы можете понять, объектов какого типа больше. Плюс, за счет того, только Wrapper'ы держат строки и другие массивные объекты, вам легче понять, какой вклад каждого конкретного типа-обертки в общую память.
  • Аргумент про jit оптимизации отчасти верен, однако он не совсем полный. Ведь за счет строгой типизации Ваше ПО начинает избавляться от многочисленных проверок на входе в функции. Все ваши модели проверяются на адекватность при своем конструировании. Таким образом в общем случае у вас будет меньше проверок (достаточно просто потребовать правильный тип). К тому же, за счет того, что проверки переносятся в конструктор, а не размазываются по коду, становится проще определить, какие из них действительно отнимают время.
  • К сожалению, в этой статье я не могу привести полноценный тест на производительность, который сравнивает проект с большим числом микротипов и с классической разработкой, используя только int, string и другие примитивные типы. Основная причина — для этого необходимо сначала сделать типовой жирный проект для теста, а потом обосновать, что именно этот проект является типовым. И вот со вторым пунктом всё сложно, так как в рельной жизни проекты действительно разные. Однако делать синтетические тесты будет довольно странно, ибо, как я уже говорил, на создание объектов микротипов в Enterprise приложениях, по моим замерам, всегда уходило пренебрежительно мало ресурсов (на уровне погрешности измерений).

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

Важно: подобными оптимизациями следует заниматься только тогда, когда вы получили гарантированные факты, что именно микротипы тормозят приложение. По моему опыту, подобная ситуация скорее невозможна. С большей долей вероятности у вас будет тормозить тот же логгер, потому что каждая операция ждет сброса на диск (причем всё было приемлемо на компьютере разработчика с M.2 SSD, однако пользователь со старым HDD видит совсем другие результаты).

Однако, сами приемы:

  • Использовать значимые типы вместо ссылочных. Подобное может оказаться полезным, если Wrapper работает тоже со значимыми типами, а значит в теории можно всю необходимую информацию передавать через стек. Хотя следует помнить, что ускорение будет только в том случае, если ваш код действительно страдает от частых GC именно по причине микротипов.
    • struct в .Net может вызывать частый boxing/unboxing. А заодно подобные структуры могут потребовать больше памяти в коллекциях Dictionary/Map (так как в них массивы выделяются с запасом).
    • inline типы из Kotlin/Scala имеют ограниченную применимость. Например, в них нельзя хранить несколько полей (что может быть иногда полезно для кеширования значения ToString/GetHashCode).
    • Ряд оптимизаторов умеют выделять память на стеке. В частности, .Net делает это для малых временных объектов, а GraalVM в Java умеет выделить объект на стеке, однако потом скопировать в кучу, если таки пришлось его вернуть (подходит для кода, богатому на условия).
  • Использовать интернирование объектов (то есть стараться брать уже готовые, заранее созданные, объекты).
    • Если у конструктора один аргумент, то можно просто сделать кеш, где ключом будет этот самый аргумент, а значением — созданный ранее объект. Таким образом, если разнообразие объектов довольно мало, вы можете просто переиспользовать уже готовые.
    • Если у объекта есть несколько аргументов, то можно просто создать новый объект, а далее проверить, нет ли его в кеше. Если там есть аналогичный, то лучше вернуть уже созданный.
    • Подобная схема замедляет работу конструкторов, так как по всем аргументам необходимо делать Equals/GetHashCode. Однако она же ускоряет будущие сравнения объектов, если вы прокешируете значение хеша, так как в этом случае, если они отличаются, то и объекты разные. А одинаковые объекты зачастую будут иметь одну ссылку.
    • Однако эта оптимизация ускорит программу, за счет более быстрых GetHashCode/Equals (см. пункт выше). Плюс время жизни новых объектов (которые однако есть в кеше) радикально упадет, так что они будут попадать разве что в Generation 0.
  • При создании новых объектов проверяйте входные параметры, а не корректируйте. Несмотря на то, что этот совет зачастую идет в пункте о стиле кодирования, на деле он позволяет увеличивать эффективность программы. Например, если ваш объект требует строку только с БОЛЬШИМИ БУКВАМИ, то зачастую используются два подхода для проверки: или сделать ToUpperInvariant от аргумента, или в цикле проверить, что все буквы большие. В первом случае гарантировано будет создана новая строка, во втором же — максимум итератор. В итоге вы экономите на памяти (однако в обоих случаях каждый символ всё равно будет проверен, так что производительность вырастет только в контексте более редкой сборки мусора).

Вывод

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

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

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

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

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

Автор: Игорь Манушин

Источник


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