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

    При построении многослойных («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: Продолжение темы: Строго типизированное представление неполных данных

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 8

      +2
      Ненормальное программирование
      Как здорово вы угадали с хабом.

      Вообще, интересная вещь, но только если количество сущностей в проекте over 9000, иначе излишнее усложнение не окупается удобством того же рефакторинга.
        0
        Здесь мы сохраняем возможность рефакторинга. Все свойства представлены объектами, хранятся в именованных константах (val'ах). С точки зрения объявления «класса» получается не сильно больше, чем объявление обычного case class'а.
        case class Person(
            name: String, 
            address: Address
        )
        

        vs.
        object Person{
            val name = slot[String]("name")
            val address = slot[Address]("address")
        }
        

          +1
          да это понятно из статьи.

          я про то, что использование подобного подхода имхо оправдано только на крупных проектах с большим количеством моделей, например.
        +2
        Все сугубо, ИМХО. «Правильной дорогой идете товарищи» (с) Одна из проблем сегодняшнего программирования, то, что объектом является аристотелева вещь с фиксированным набором свойств. Вот один из примеров, почему это сильно усложняет программирование сложных систем.
        Человек рождается с очень ограниченным набором свойств: иметь возраст, вес, рост, пищать, питаться и портить подгузник. Время идет, и он приобретает новые наборы свойств: ученик школы, покупатель, пассажир, солдат, студент, наемный работник, предприниматель, супруг, родитель и т.д. А возможно и не приобретает. Например, не каждый человек служит в армии, учится в вузе, женится и становится отцом или предпринимателем. Или утрачивает. Например, закончил учиться, отслужил в армии или развелся. Следовательно, один и тот же объект должен иметь возможность принадлежать разным классам и этот набор классов должен быть динамическим, т.е. изменяться в ходе эволюции объекта и самой программной системы. На набор классов, к которым относится объект, как правило, накладываются ограничения. Например. Чтобы стать солдатом, человек должен достичь 18 лет. А если человек студент то для того, чтобы стать мужем, необходимо сдать сопромат.

        То, что вы описали, как я понял, похоже на путь к решению этой проблемы. Следующий шаг — осознать, что свойство != имя + значение, а является полноценным объектом, который проявляется во взаимодействии со свойствами других вещей.

        Успехов.
          0
          HList напоминает Union Type. Наверное мне пора серьёзнее посмотреть на Shapeless.

          Не совсем понятно применение. Экономии на количестве классов в JVM не получается, потому что «object Person» создаст класс «Person$». Чтобы избежать copy-paste на однотипных операциях можно использовать дженерики, хоть там type erasure и ограничивает возможности узнавать тип в рантайме. Но в паре с Typeclass pattern (см. например мой блог) можно это обойти, да ещё и перенести обработку выбора кода для конкретного типа на этап компиляции.

          Пример с delta был бы интересен. Особенно чем он отличается от

          case class Person(val name: String, val address: Option[String])
          val john = Person("John", None)
          val john2 = john.copy(address = Some("22b Baker st")
          
            +1
            1. UnionType (by Miles Sabin) используется для того, чтобы сформировать объединённый тип всех свойств, которые уже использовались в текущем объекте

              type SlotsUnion = head.type with tail.SlotsUnion
            

            (Здесь мы используем оператор with, который соответствует конъюнкции. Для проверки принадлежности мы инвертируем условие с помощью оператора <:<

            def get[SlotType<:SlotId[_](slotId:SlotType)(implicit ev: SlotSeq1#SlotUnion <:< SlotType) = ???
            


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

            3. Пример с delta. Пусть класс Person содержит 100 полей. И пусть среди этих полей есть свойство типа T: Numeric (например, Int, Double и т.п.). Тогда мы можем объявить специальный класс PropertyDelta, который несёт изменение этого свойства

              case class SlotDelta[T:Numeric, S <: SlotId[T]](slotId:S, delta:T)
            


            Это может быть экономнее, чем копировать оставшиеся 99 полей (при сохранении в БД, при передаче по сети и т.п.). Кроме того, здесь мы явно видим, что именно изменилось, можем проверить допустимость изменения этого свойства согласно каким-либо правилам.

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

              case class SlotDelta[T:Numeric, S <: SlotId[T]](slotId:S, delta: T) {
                def addTo(oldValue:SlotValue[T, S]) =
                  SlotValue(slotId, implicitly[Numeric[T]].plus(oldValue.value, delta))
              }
            


            Или немного поинтереснее:

              implicit class SlotValueEx[T:Numeric, S <: SlotId[T]] (slotValue:SlotValue[T, S]) {
                def +(delta:T) =
                  SlotValue(slotValue.slotId, implicitly[Numeric[T]].plus(slotValue.value, delta))
              }
            

              0
              P.S. Вышеприведённый вариант — для общего случая, для Int, Double, BigNumber… и других T, для которых есть Numeric[T]. Если delta имеет известный тип, то можно и проще:
                case class SlotDelta[S <: SlotId[Int]](slotId:S, delta: Int) {
                  def +:(oldValue:SlotValue[Int, S]) =
                    SlotValue(slotId, oldValue.value+ delta)
                }
              

                0
                Спасибо за пояснения. Да, действительно интересный подход.

              Only users with full accounts can post comments. Log in, please.