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

В предыдущей статье «Конструирование типов» была описана идея, как можно сконструировать типы, похожие на классы. Это даёт возможность отделить хранимые данные от метаинформации и сделать акцент на представлении самих свойств сущностей. Однако описанный подход оказывается довольно сложным из-за использования типа 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-проверки —

недопустимость использования свойств, не входящих в схему; обеспечение полноты формируемого объекта. Для представления данных в строках таблиц в БД необходимо преобразовать вложенные Record’ы в плоскую структуру. Для этого используется пара методов align/unalign.

Заключение Изложенный подход позволяет

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

© Habrahabr.ru