Pull to refresh

Строго типизированное представление неполных данных

Reading time8 min
Views7.6K
В предыдущей статье «Конструирование типов» была описана идея, как можно сконструировать типы, похожие на классы. Это даёт возможность отделить хранимые данные от метаинформации и сделать акцент на представлении самих свойств сущностей. Однако описанный подход оказывается довольно сложным из-за использования типа HList. В ходе развития этого подхода пришло понимание, что для многих практических задач линейная упорядоченная последовательность свойств, как и полнота набора свойств, не является обязательной. Если ослабить это требование, то конструируемые типы значительно упрощаются и становятся весьма удобны для использования.

В обновлённом варианте библиотеки synapse-frames исключительно просто описываются иерархические структуры данных и представляются любые подмножества таких структур.



Двусторонне-типизированные отношения



Свойство объекта обычно рассматривают в привязке к самому объекту и в таком случае свойство имеет тип данных. Один тип — только для ограничения данных, которые могут в свойстве содержаться. Логичным поэтому выглядело представить свойство как Slot[T]. Однако свойство также привязано к типу объекта, в котором это свойство объявлено, хотя и не очень явным способом. В вышеупомянутой статье для установления такой связи конструировался новый суррогатный тип из набора свойств.

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

sealed trait Relation[-L,R]
case class Rel[-L, R](name: String) extends Relation[L, R]

(значок -L означает «контравариантность», т.е. свойство будет доступно и у потомков типа L. А тип R объявлен инвариантным, т.к. для свойства мы планируем использовать и getter'ы и setter'ы)

Класс Rel позволяет нам описать атрибуты, доступные у типа L. Например,

class Box

val width = Rel[Box, Int]("width")
val height = Rel[Box, Int]("height")


(эти же свойства будут доступны у потомков типа Box).

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

Для типа L нам надо иметь какой-то реальный тип. В предыдущем варианте мы этот тип конструировали как HList над входящими в этот тип свойствами. Здесь же в качестве типа L можно использовать произвольный тип, доступный в Scala. Например, любой примитивный тип, или любой type alias, можно использовать trait'ы, abstract и final классы, object.type'ы. Благодаря контравариантности L мы можем использовать отношение наследования между типами, которые используем в качестве носителей свойств. По-видимому, удобно отразить отношение наследования в виде набора abstract class'ов, trait'ов и final class'ов в соответствии с логикой предметной области.

abstract class Shape
trait BoundingRectangle

final class Rectangle extends Shape with BoundingRectangle
final class Circle extends Shape with BoundingRectangle

val width = Rel[BoundingRectangle, Int]("width")
val height = Rel[BoundingRectangle, Int]("height")

val radius = Rel[Circle, Int]("radius")


Отдельный атрибут можно рассматривать как один компонент, позволяющий переходить от родительского объекта к дочернему. Если дочерний имеет свои атрибуты, то можно осуществить навигацию по любому из них. Пара таких атрибутов может быть объединена в путь от «дедушки» к «внуку» и будет получено новое отношение (Rel2(attr1, attr2)).

  case class Rel2[-L, M, R](_1: Relation[L, M], _2: Relation[M, R])
    extends Relation[L, R]


В DSL добавлен метод `/`, конструирующий Rel2, тем самым осуществляя композицию отношений.

Также хотелось бы отметить, что такие отношения являются неотъемлемой частью троек, составляющих основу онтологий RDF/OWL. А именно, отношения представляют собой средний компонент тройки:
(идентификатор объекта типа L, идентификатор свойства Relation[L,R], идентификатор значения свойства типа R).

Строго типизированные идентификаторы



При использовании неполного описания объекта через набор атрибутов, весьма важным оказывается вопрос сопоставления разных наборов атрибутов с одним и тем же экземпляром. Необходимо каким-либо образом отразить свойство аутентичности экземпляра самому себе. В ООП для этой цели может использоваться факт принадлежности значений атрибутов одному и тому же объекту. В БД обычно используется какой-либо способ идентификации. Равенство идентификаторов объектов позволяет вывести аутентичность рассматриваемых объектов.

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

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

trait Id[T]


Однако, такой способ идентификации оказывается не универсальным. Во-первых, многие объекты идентифицируются только в пределах родительских объектов; во-вторых, многие типы объектов могут иметь сразу несколько способов идентификации. Для отражения первого явления мы можем использовать описанный выше тип Rel[-L,R], рассматривая его уже как способ перехода от родительского объекта к конкретному экземпляру дочернего объекта. Если вспомнить, что дочерние объекты зачастую объединяются в типизированные коллекции, то идентификатор дочернего объекта оказывается составным — вначале выбирается коллекция, а затем по целочисленному индексу выбирается элемент этой коллекции:

  val children = Rel[Parent, Seq[Children]]("children")

  case class IntId[T](id: Int) extends Relation[Seq[T], T]

  val child123 = children / IntId(123)

(здесь используется DSL-метод `/`, объединяющий два отношения в одно (композиция отношений)).

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

  trait IndexedCollection[TId, T]

  case class Index[TId, T](keyProperty: Relation[T, TId])
    extends Relation[Seq[T],IndexedCollection[TId, T]]

  case class IndexValue[TId, T](value:TId)
    extends Relation[IndexedCollection[TId, T], T]


Например:

  val name = Rel[Child, String]("name")
  
  val childByName = name.index

  val childVasya = parent / children / childByName / IndexValue("Vasya")


Таким образом, тип Rel[-L, R], расширенный порядковым номером в коллекции и индексом по свойству дочернего объекта, позволяет осуществлять навигацию в иерархической структуре данных.

Чтобы идентифицировать объекты, находящиеся на самом верхнем уровне и не имеющие родительского объекта, можно ввести специальный тип Global, который будет содержать все коллекции высокоуровневых объектов:
  final class Global
  val persons = Rel[Global, Seq[Person]]("persons")
  val otherTopLevelObjects =
    Rel[Global, Seq[OtherTopLevelObject]]("otherTopLevelObjects")


Схема данных



Отношения сами по себе являются кирпичиками, позволяющими строить как сами структуры данных, так и схемы этих данных. Для описания схемы данных можно использовать реляционный подход — сущность-связь. В этом случае схема представляет собой коллекцию описаний сущностей и коллекцию описания связей между сущностями. Для сущностей указывается набор атрибутов, а для отношений — 1-0, 1-1, 1-*, *-*

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

Реляционная схема, понятное дело, прекрасно подходит для представления данных в БД, а объектно-ориентированная может использоваться для создания объектно-ориентированных сервисов (web-services?).

Для описания типа T в объектно-ориентированном варианте схемы используется один из потомков Schema[T].
SimpleSchema — для простых типов, не содержащих атрибуты;
RecordSchema — составные типы, содержащие указанные атрибуты;
CollectionSchema — для типов Seq[T] позволяет привязать схему элементов коллекции.

Хранение данных



Метаинформация сама по себе не содержит данных. Для хранения необходимо использовать другие структуры. Такие структуры зависят от потребностей приложения:
  • обычные классы с обычными свойствами, доступ к которым осуществлятся с помощью reflection'а по именам свойств;
  • специальные классы для хранения данных, содержащие также и метаинформацию — наследники Instance[T] (SimpleInstance, RecordInstance, CollectionInstance). Эти типы упрощают работу с данными, описываемыми схемой, т.к. хранение данных напрямую соответствует схеме;
  • линейный кортеж, «список списков» (List[Any]). Иерархическую структуру вложенных Record'ов можно разложить в линейную структуру — последовательность примитивных типов. Вложенные коллекции превращаются в списки-списков простейших типов. Такое представление может использоваться для передачи по сети и для взаимодействия с БД (т.к. кортеж прямо соответствует строке таблицы). Для конвертации Instance'ов в плоские списки и обратно используется пара операций align/unalign (flatten);
  • таблицы БД, данные из которых извлекаются с помощью RecordSet'а;
  • JSON-объекты;
  • XML.


Конструирование данных



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

  val b1 = empty[Box]
	  .set(width, simple(10))
	  .set(height, simple(20))


Здесь используется immutable тип Instance[Box], в который добавляются пары — (свойство, значение). В случае, если данных немного, такой подход достаточен. Если требуется собирать много данных, то более эффктивно использовать mutable билдер, внутри которого постепенно формируется требуемый комплект атрибутов. По окончании сборки билдер преобразуется в Instance[Box]:

val boxBuilder = new Builder(boxSchema)
boxBuilder.set(width, simple(10))
boxBuilder.set(height, simple(20))
val b1 = boxBuilder.toInstance


Также билдер обеспечивает две runtime-проверки —
  1. недопустимость использования свойств, не входящих в схему;
  2. обеспечение полноты формируемого объекта.


Для представления данных в строках таблиц в БД необходимо преобразовать вложенные Record'ы в плоскую структуру. Для этого используется пара методов align/unalign.

Заключение



Изложенный подход позволяет
  • описывать сложные предметные области с явным сохранением метаинформации;
  • оперировать свойствами строго типизированным образом (с проверкой типов на этапе компиляции);
  • представлять произвольные иерархические структуры данных (наподобие json'а) с проверкой типов на всех уровнях;
  • представлять неполные данные и проверять степень полноты (например, можно иметь smallSchema[T] и fullSchema[T], с помощью которых проверять экземпляры данных).


В отличие от подхода, описанного в предыдущей статье, мы ослабляем требование обеспечения проверки полноты данных на этапе компиляции. Взамен получается гораздо более простой и удобный подход. Допустимость использования свойства на указанном типе проверяется компилятором без построения громоздких суррогатных типов на базе HList. В то же время, мы не скованы объектно-ориентированным подходом в плане представления данных и ограничения состава атрибутов сущности.
Tags:
Hubs:
+17
Comments2

Articles