Pull to refresh

Конструирование типов в Scala

Reading time 5 min
Views 9.4K
При построении многослойных («enterprise») систем часто оказывается, что создаются ValueObject'ы (или case class'ы), в которых хранится информация о каком-либо экземпляре сущности, обрабатываемом системой. Например, класс

case class Person(name: String, address: Address)


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


так и некоторыми недостатками:
  • если сущностей много, то таких классов также становится довольно много, а их обработка требует много однотипного кода (copy-paste);
  • потребности отдельных слоёв системы в метаинформации могут быть представлены аннотациями к свойствам этого объекта, но возможности аннотаций ограничены и требуют использования reflection'а;
  • если требуется представить данные не обо всех свойствах объекта сразу, то созданные классы использовать затруднительно;
  • затруднительно также представить изменение значения свойства (delta).


Мы хотим реализовать фреймворк, позволяющий создавать новые «классы» (типы, конструкторы этих типов, объекты новых типов) инкрементно, используя наши собственные «кирпичики». Попутно, пользуясь тем, что мы сами изготавливаем «кирпичики», мы можем достичь таких полезных свойств:
  • возможность описывать отдельные свойства сущностей (с указанием типа данных в этом свойстве и любой метаинформации, необходимой приложению, в форме, подходящей именно для этого приложения);
  • возможность оперировать со свойствами экземпляров строго типизированным образом (с проверкой типов на этапе компиляции);
  • представлять частичную/неполную информацию о значениях свойств экземпляра сущности, пользуясь объявленными свойствами;
  • создавать тип объекта, содержащего частичную информацию о свойствах экземпляра сущности. И использовать этот тип наравне с другими типами (классами, примитивными типами и др.).


Чтобы сконструировать новый составной тип, надо разобраться, как устроен обычный класс. В объявлении класса Person можно выделить компоненты
  • упорядоченный список свойств/слотов (slot sequence),
  • имя свойства/слота (slot id),
  • тип свойства/слота.


При использовании класса Person и его свойств можно выделить операции —
  • получения значения свойства экземпляра (экземпляр.name)
  • получения нового экземпляра с изменившимся свойством (так как класс Person — immutable, для mutable классов аналогом является изменение значения свойства объекта)


При этом сущностью «первого класса» является сам класс Person, а его свойства — сущности «второго класса». Они не являются объектами и мы не имеем возможности оперировать с ними отвлечённо.

Мы же хотим свойства сделать самостоятельными сущностями «первого класса», из которых уже будет конструироваться новый «класс».

Итак, объявим свойство name:

trait SlotId[T]

case class SlotIdImpl[T](slotId:String, ...) extends SlotId[T]

def slot[T](slotId:String, ...) = SlotIdImpl[T](slotId, ...)

val name = slot[String]("name", ...)


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

Последовательность слотов


Чтобы получить новый тип, надо собрать несколько свойств в упорядоченный список. Для конструирования типа, составленного из других, будем использовать такой же подход, как в типе HList (из замечательной библиотеки shapeless, например).

sealed trait SlotSeq {
   type ValueType <: HList
}
case object SNil extends SlotSeq {
   type ValueType = HNil
}
case class ::[H<:SlotId, T<:SlotSeq](head:H, tail : T) extends SlotSeq {
   type ValueType = H :: T#ValueType
}


Как видно, в процессе конструирования списка свойств мы также конструируем тип значения (ValueType), совместимого со списком свойств.

Группировка свойств


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

object PersonType {
  val name = slot[String]("name", ...)
  val address ...
  ...
}


Такую группировку также можно делать с помощью trait'ов, что позволяет объявлять одинаковые свойства в разных «гроздях».

trait Identifiable {
  val id = slot[Long]("id")
}

object Employee extends Identifiable


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

Представление экземпляров


Собственно, данные, относящиеся к сущности, могут быть представлены в двух основных формах: Map или RecordSet. Map — содержит пары свойство-значение, в то время как RecordSet содержит упорядоченный список свойств и массив значений, расположенных в том же порядке. RecordSet позволяет экономно представить данные о большом количестве экземпляров, а Map позволяет создать «вещь в себе» — изолированный объект, который содержит всю метаинформацию вместе со значениями свойств. Оба этих способа могут использоваться параллельно в зависимости от текущих потребностей.

Для типизированного представления строк RecordSet'а может использоваться замечательная структура HList (из библиотеки shapeless, например). Нам надо лишь в процессе сборки упорядоченного slot sequence'а формировать тип совместимого HList'а.

type ValueType = head.Type :: tail.ValueType


Для создания строготипизированного Map'а нам потребуется вместо обычного класса Entry использовать свой класс SlotValue,

case class SlotValue[T](slot:SlotId[T], value:T)


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

Заключение


Кроме вышеописанной базовой структуры данных и структуры типов, полезными являются вспомогательные функции, построенные на базовом инструментарии
  • постепенное конструирование экземпляра Map (строго типизированный MapBuilder);
  • линзы для доступа и модификации вложенных свойств;
  • конвертация Map — в RecordSet и обратно


Такой фреймворк может применяться при необходимости обработки разнотипных данных на основе метаинформации о свойствах, например:
  • работа с БД:
  • однотипная обработка событий, относящихся к свойствам различных сущностей, например, изменение свойств объектов.

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

Код для описанных конструкций .
UPD: Продолжение темы: Строго типизированное представление неполных данных
Tags:
Hubs:
+12
Comments 8
Comments Comments 8

Articles