Допустим, у вас есть объект домена, смоделированный примерно так:

в основном это нормально, но ему не хватает точности типа - мы хотели бы более детально рассуждать о различных типах полей.

В настоящее время существуют две основные школы по соответствующему обогащению типов. Один из них использует теги:

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

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

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

Итак, поговорим о теории - и о практике. Но сначала краткий обзор.

Ценностные классы - почему?

Классы значений - это классы с ровно одним атрибутом значения. Эквивалентно:

а также:

У них есть два тесно связанных преимущества:

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

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

Классы значений - на практике

Итак, каковы исключения в контексте создания экземпляров? Цитата из документации:

Фактически экземпляр класса значений создается, когда:

1. класс значений рассматривается как другой тип.

2. массиву присваивается класс значений.

3. выполнение тестов типа во время выполнения, таких как сопоставление с образцом.

На первый взгляд, это не так уж и плохо.

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

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

Однако я хочу сосредоточиться на чем-то более важном для разработки программного обеспечения на Scala. Правильно - это десериализация JSON.

Настройка

Мы собираемся использовать Scala 2.12 и Oracle JDK для Java 8.

Начнем с типичной настройки HTTP-проекта Akka. Вот шаблон.

Как видите, у нас по-прежнему отсутствует сам домен, поэтому мы стараемся сделать его максимально простым:

И, наконец, кодировка. В данном случае мы используем circe, который предоставляет удобный полуавтоматический декодер для классов значений.

Для фактического Message декодер будет полностью ручным, чтобы гарантировать, что любые проблемы могут быть полностью изолированы от декодирования самого Text.

Вот все, что нам нужно:

Давайте попробуем запустить Main, подключиться jvisualvm и выполнить пару POST запросов (используя HTTPie):

Это не выглядит хорошо:

Ясно, что создается экземпляр Text.

Изоляция проблемы

Давайте исключим посредника и просто попробуем декодирование без какого-либо подключения к HTTP:

Давай попробуем и ...

domain.Message      16	16 (0.0%)	1 (0.0%)
domain.Message$Text 16	16 (0.0%)	1 (0.0%)

Ага, тоже самое.

Выглядит плохо, но все же может быть какой-то случайностью или причудой того, как работает jvisualvm. Мы можем устранить это, углубившись во внутреннее устройство, начав с запуска async-profiler, который теперь недавно интегрирован в IDEA в EAP-версии. Таким образом, мы сможем проверить соответствующие точки вызова.

Хотя явных экземпляров (<clinit> вызовов) не видно, мы действительно можем видеть, что tDecoder вызывается вместе с несколькими методами макросов.

Это, конечно, ожидаемо. В конце концов, нужно использовать методы декодирования. Так что это направление расследования - провал - не считая того, что действительно задействованы «наши» декодеры.

Обратимся к байт-коду Transcoding (который довольно существенен, поэтому я избавлю вас от обзора).

При поиске ссылок на Text сразу же находим следующее:

Это синтетический метод, принимающий Object (1), преобразование его в Text (2) и получение значения поля (3) для инициализации Message (4).

Метод, в свою очередь, вызывается здесь:

Предыдущий метод вызывается в (1). Обратите внимание на одну из последних строк, определяющую локальную переменную c. Теперь мы смотрим на байт-код анонимной функции в Decoder.instance для Message, то есть:

По-видимому, здесь что-то принудительное создание экземпляра.

Попытка обходного пути

ПРИМЕЧАНИЕ: следующий раздел посвящен в значительной степени исследовательскому анализу и не является существенным для понимания сути этого сообщения - не стесняйтесь переходить к «Становится хуже!» если не хватает времени. В противном случае читайте дальше.

Тонкая настройка декодера

По прихоти, давайте попробуем не использовать декодер распаковки для Text:

… И:

нет Text! Хорошо, возможно, это связано с декодером для Text? Давайте проверим это самостоятельно:

domain.Message$Text	16	16 (0.0%)	1 (0.0%)

А как насчет простого вызова декодера?

domain.Message$Text	16	16 (0.0%)	1 (0.0%)

То же самое!

Ни то, ни другое тебя не спасет

В качестве проверки работоспособности давайте смоделируем нашу десериализацию напрямую, без кодировщика:

domain.Message$Text	16	16 (0.0%)	1 (0.0%)

Очевидно, даже создание Either вызовет создание экземпляра текста! Мы можем увидеть это в байт-коде здесь:

Контрольная последовательность конструкторов _23 _ / _ 24_ / load-arg / INVOKESPECIAL появляется в самом конце.

… И декодер тоже

Также возможно, что сам декодер может инициировать создание экземпляра.

Вот соответствующий код подтверждения:

Вышеупомянутое может показаться устрашающим, но на самом деле это не так уж и сложно:

  1. Декодер работает, обрабатывая классы case через shapeless Generic.
    * A Generic - это то, что преобразуется в тип и обратно (параметр A, в нашем случае Text), и его HList представление (Lazy используется для предотвращения неявного ошибки разрешения в некоторых сценариях, и в противном случае не имеет никакого отношения к тому, как создается декодер).
    * Hlist - это типизированный список, представляющий (упрощенный) поля типа. В нашем случае у нас есть только одно поле, что означает представление String :: HNil (HNil то же самое, что Nil для "обычных" списков).
    * в нашем случае A это Text и R это String.
  2. если требуемый Generic найден (предоставляется как для классов случаев, так и для классов значений shapeless)…
  3. «сырое» значение соответствующего JSON (в нашем случае String) анализируется и используется для создания HList из unwrapped :: HNil
  4. … И преобразован через Generic в целевой тип, то есть в наш Text в данном случае.

Возможная эмуляция этого процесса:

domain.Message$Text	16	16 (0.0%)	1 (0.0%)

Ага, мы все еще находимся на пути к минимальному снижению проблем.

Если вы уже запутались («Действительно ли AnyVals работают?»), Будьте уверены, что, например, следующий:

import domain.Message.Text
object MainSanityCheckForRaw extends App {
  val text = Text("blah")
  System.in.read()
  println(text.value)
}

не создает экземпляров Text. Все переводится в статические вызовы.

Еще обходные пути?

«ОК» - теперь вы говорите - «вы вручную создали декодер для самого Message»:

"Что произойдет, если вы пойдете в другом направлении и замените его полуавтоматическим?"

Запускаем MainDecode снова:

domain.Message          16	16 (0.0%)	1 (0.0%)
domain.Message$Text	16	16 (0.0%)	1 (0.0%)

Та же история, вероятно, по тем же причинам.

Становится хуже!

К настоящему времени вы, возможно, заметили, что постоянный элемент - это не какой-либо API, который мы используем, а что-то, связанное с «упаковкой» классов значений в параметризованный тип.

Нам нужно подтвердить гипотезу, полностью исключив circe, shapeless и т. Д.

Неявное разрешение

Сначала мы создадим настраиваемую иерархию неявного разрешения. Допустим, у нас есть класс типа для вычисления длины строкового представления данного класса, который называется StringMeasure:

Теперь мы можем увидеть, что произойдет, если мы реализуем экземпляр для Text, разрешив «базовый» случай с помощью имплицитов:

Вы, наверное, не очень удивитесь, что мы получим:

domain.Message$Text	16	16 (0.0%)	1 (0.0%)

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

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

Но действительно ли нам нужно неявное разрешение для запуска нашего теперь любимого граничного условия?

Виновник - параметры типа

В качестве последнего примера давайте посмотрим, что произойдет, когда мы передадим наш класс значений через простой параметризованный метод:

domain.Message$Text	16	16 (0.0%)	1 (0.0%)

И, чтобы было ясно, если мы переключим getThing на def getThing(text: Text): Text = text, класс значений не создаст экземпляр.

Бонус - помогает ли специализация?

MainSanityCheckForDef$Number	16	16 (0.0%)	1 (0.0%)

Неа. Так что нет надежды на классы значений, содержащие примитивы.

Наконец - «ПОЧЕМУ»

Итак, теперь мы видим, что проблема действительно заключается в параметризации. Это вызывает создание экземпляра с неявным разрешением circe, создание бесформенного Generic и т. Д. И т. Д.

Но почему?

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

Фактически, класс значений создается, когда:

1. класс значений рассматривается как другой тип.

2. массиву присваивается класс значений.

3. выполнение тестов типа во время выполнения, таких как сопоставление с образцом.

Наиболее вероятным условием здесь является первое - параметр типа считается «другим типом» (в некоторых спецификациях для Дотти есть дополнительное доказательство этого; мы вернемся к этому в конце поста).

К сожалению, SIP не может предоставить релевантных примеров, поэтому я не на 100% уверен, что на самом деле запускает условия.

Мое предположение сводится к следующему: из-за стирания типа в JVM при генерации байт-кода для параметров типа у вас есть два варианта:

  • либо вы создаете полиморфные дубликаты всех соответствующих методов для ваших типов (как в специализации),
  • или вы должны передать все как java.lang.Object.

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

Что теперь?

Вам может быть интересно, не пора ли сейчас паниковать. И ответ, конечно, нет.

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

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

Двойная проверка

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

Теоретически можно было бы создать плагин, который проверяет, создаются ли классы значений во время тестов, возможно, используя sbt-jmh или аналогичный в качестве основы. Помимо фактического уговора JMH или другого бенчмарка / профилировщика для предоставления правильной информации, вам, вероятно, также потребуется разработать некоторую эвристику для определения классов значений через структуру, поскольку в противном случае они кажутся идентичными нормальным классам - единственная разница - это шаблоны вызывающих и дополнительные статические методы.

Снижение шума

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

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

Для новых проектов

Однако, если вы создаете новый проект, учтите, что еще раз:

Все, что использует параметризованное разрешение, например:

  • автоматическая генерация кодеров / декодеров JSON,
  • любые универсальные преобразователи,
  • на самом деле все, что генерирует иерархии ADT, с неявными или иными словами,

создаст экземпляры ваших классов значений (за исключением некоторых очень специфических оптимизаций компилятора, таких как например, здесь).

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

Опять же, замечание о фактическом неиспользовании квалификатора AnyVal в предыдущем разделе? Это также применимо, когда вы используете их исключительно как DTO / объекты домена (без методов-членов и т. Д.). Вы же не хотите развивать карго-культовое программирование, не так ли?

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

Дотти FTW

Наконец, чтобы закончить на более яркой ноте, Дотти рассматривает возможность введения непрозрачных типов. Это, по-видимому, предназначено для замены классов стоимости.

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

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

Ищете экспертов по Scala и Java?

Свяжитесь с нами!

Мы заставим технологии работать на ваш бизнес. Посмотреть проекты, которые мы успешно реализовали.