Висновок реалізацій інтерфейсів в Scala c бібліотекою Shapeless

У статті розглянемо приклад перетворення даних алгебраїчного типу в уявленні через sealed trait family в узагальнене уявлення. Покажемо техніки роботи з цим узагальненим поданням на прикладі структурного порівняння, операції diff. В кінці статті — працюючий приклад в репозиторії на GitHub.

Мотивація

Напевно багатьом програмістам, які пишуть на статично типізованих мовах, часто доводиться мати справу з введенням операції порівняння (метод equals, операція == і т. д.). У більшості мов ця операція вводиться безпосереднім написанням коду операції. Найчастіше це може виглядати якось так:

class Foo {
 private var bar: Int
 private var baz: Double
 private var qux: String

 override def equals(that: Foo): Boolean = {
 this.bar == that.bar && this.baz == that.baz && this.qux == that.qux
}
}

Однак написання такого коду буває досить громіздким і трудомістким, особливо для великої кількості класів.

Ще одна проблема, з якою, можливо, багато читачі могли зіткнутися при тестуванні — це пошук причини, по якій два об'єкта не рівні, і конкретних полів, які відрізняються. Написання коду, який би по заданому об'єкту безпосередньо відшукував і перераховував всі відрізняються поля, знову призвело б до написання великої кількості бойлерплейта і складної інфраструктури.

Хотілося б мати магічний метод Compare(a,b), який би працював для великої кількості типів і в той же час був легко настроюється.

Вирішити завдання з написання цього методу так, щоб бойлерплейта довелося писати по мінімуму, можна кількома способами. По-перше, можна застосувати рефлексію для виведення механізму порівняння типів даних, але рефлексія славиться монструозностью коду. І, що найголовніше, рефлексія працює під час виконання коду, що загрожує помилками часу виконання, які, в принципі, можна відловити на етапі компіляції. По-друге, можна застосувати кодогенерацию (шаблони/макроси), яка б генерувала весь бойлерплейт, однак, так чи інакше, потрібно буде ускладнювати процес складання і компіляції і виносити генерування всього бойлерплейта в окремий етап. Зазвичай це ускладнене слаборозвиненим тулингом для рефлексії часу компіляції і погіршення складанням.

Проте в мові Scala є рішення, яке спрощує подібну кодогенерацию, прибираючи будь-яку необхідність писати код «збоку» програми і як-небудь модифікувати складання та компіляцію проекту. Це рішення, бібліотека Shapeless, є набором макросів і досить хорошого API до них, яка надає різні абстракції для узагальненого програмування, такі як гетерогенний список, копроизведение, функції з залежним від типу параметра типом значення, що повертається, і т. д. зокрема, ця бібліотека надає механізм перетворення обмежених ієрархій кейс-класів в гетерогенні списки, який і буде використовуватися для вирішення завдання.

Будемо вирішувати цю задачу для сімейств запечатаних трейтов, користуючись їх хорошими властивостями.

Сімейства запечатаних трейтов в Scala (sealed trait family)

В Scala конструкції, що складаються з певної кількості «інтерфейсів», доступних до розширення в рамках одного файлу (sealed trait), і класів, що містять тільки незмінні дані (case class і case object), їх нащадками, називаються обмеженою ієрархією кейс-класів. Ця конструкція досить непогано справляється з моделюванням замкнутих ADT (Алгебраїчна Data Type).

Пара прикладів:

sealed trait Color

sealed trait PredefColor extends Color
case object Red extends PredefColor
case object Green extends PredefColor
case object Blue extends PredefColor

case class RGB(r: Byte, g: Byte, b: Byte) extends Color
case class RGBA(r: Byte, g: Byte, b: Byte; a: Byte) extends Color
case class HSV(h: Byte, s: Byte, v: Byte) extends Color

Або

case class Id(text: Id)
sealed trait WithId{ def id: Id }
sealed trait UserData{
 def login: String
 def email: String
}
sealed trait AdminPriviledges {def privileges: Set[String]}
sealed trait User
case object Unregistered extends User
case New class(id: Id) extends WithId
case class LoggedUsser(id: Id, login: String, email: String) extends WithId with UserData
case class Admin(id: Id, login: String, email: String, priviledges: Set[String]) extends WithId 
 with UserData with AdminPriviledges

У свою чергу, програми на Scala дуже часто пишуться з використанням принципу відокремлення даних від операцій над ними, і ADT досить добре підходить для опису даних у таких програмах.

ADT має ряд хороших властивостей, наприклад, дані иммутабельные і не містять ніяких складних процедур побудови внутрішнього стану. Або ж ADT замкнуто (тобто те, що успадковано від одного sealed trait, знаходиться всередині одного файлу), що дозволяє робити повний перебір по структурі ADT, будучи впевненим, що всі варіанти будуть перебрані. Крім цього, всі поля всередині кінцевих кейс-класів — відкриті, що разом з попереднім фактом робить ADT дуже зручним типом даних з простою і зрозумілою структурою.

Имплиситы і тайпклассы в Scala

Тайпкласс , як свідчить Вікіпедія, це конструкт для ad-hoc поліморфізму. Іншими словами, спосіб для вибору конкретної реалізації поліморфної функції за типом аргументу.

Реалізація тайпкласса в Scala використовує неявні значення і неявні аргументи функцій, які в цій мові є дуже потужним засобом. Взагалі-то на Хабрахабре з цього приводу є досить непогана стаття , але короткий їх опис, необхідне для вирішення завдання, буде приведено нижче.

По-перше, існують неявні значення, вони позначаються ключовим словом implicit, наприклад:

implicit val foo: Int =5
implicit val 

Існують неявні аргументи функції, які позначаються окремим блоком параметрів:

def functionWithImplicitParameters( regularParam: String
)(implicit
 implicitParam1: String,
 implicitParam2: Int
): (String, String, Int)= (regularParam, implicitParam1, implicitParam2)

На місця неявних параметрів у місці виклику функції компілятор під час компіляції підставляє перші найбільш підходящі параметри. Якщо ж параметрів одного типу на місце одного неявного параметра претендує більше одного, це викликає помилку компіляції diverging implicit expansion.

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

type T1
type T2
type T3

implicit def foo(implicit t1: T1): T2
implicit def bar(implicit t2: T2): T3

implicit val t1: T1 = ???

val res = bar

В даному випадку виклик

bar

розгорнеться до

bar(foo(t1))

Для типів неявних параметрів немає строгих обмежень: типи неявних параметрів і значень можуть бути як функціями, так і вищими кайндами.

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

type Bar[T]
def foo[F](implicit b: Bar[F]): Unit

в

def foo[F : Bar]:Unit

За умови, що тип Barмає місце для одного параметра.

Є ще один тип имплиситов — неявні класи. Вони можуть мати тільки один параметр, і в основному використовуються для додавання функціональності існуючих об'єктів, таким чином щоб не змінювати код самих існуючих об'єктів.

Наприклад:

sealed trait Color

sealed trait PredefColor extends Color
case object Red extends PredefColor
case object Green extends PredefColor
case object Blue extends PredefColor

case class RGB(r: Int, g: Int, b: Int) extends Color

implicit class ToRgbOps(val color: PredefColor) extends AnyVal {
 def toRgb: RGB = {
 case Red => RGB(255 byteValue, 0, 0)
 case Green => RGB(0, 255 byteValue, 0)
 case Blue => RGB(0, 0, 255 byteValue )
}
}

І тоді ми зможемо писати Red.toRgb або Green.toRgb.

Це дуже зручний інструмент для запобігання перетворення класу в кілометрову простирадло, особливо разом з тайпклассами.

Отже, переходимо безпосередньо до самих тайпклассам. Кожен тайпкласс умовно складається з 4-х компонентів. Перший — це інтерфейс, який заявляє саму функціональність тайпкласса.

Наприклад:

trait Show[A] {
 def show(a: A): String
}

Другий компонент — це companion object цього трейта, який дасть можливість використовувати певний синтаксис.

object Show {
 def apply[A](a: A)(implicit show: Show[A]): String = show.show(a)
}

А сам синтаксис використання буде виглядати так:

Show[A](a)

Третій компонент — це набір реалізацій: implicit val showString: Show[String] = {s => s} /* синтаксичний цукор для анонімних класів c одним методом був створений, тому що писати new Foo {override def baz} щоразу занадто громіздко*/

implicit val showString: Show[String] = {s: String => s} /* синтаксичний цукор для анонімних класів c одним методом, який був створений, тому що писати new Foo {override def baz} щоразу занадто громіздко*/

implicit val showInt: Show[Int] = {i: Int => i.toString}
implicit def showList[T : Show]: Show[List[T]] = {list: List[T] =>
 list.map{Show[T].apply _}.mkString("List(", ",", ")")
}

Четвертий, не обов'язковий, але вкрай бажаний компонент — це «синтаксис» тайпкласса, який дозволить викликати цей метод через точку:

implicit class ShowSyntax[T](val t: T) extends AnyVal {
 def show(implicit instance: Show[T]): String = instance.show(t)
}

А виклик буде виглядати ось так:

println(List(1,2,3,4,5,6).show)

Деконструкція sealed trait family

Тепер ми спробуємо побудувати sealed trait family з набору «примітивних» типів. Для цього нам потрібні наступні компоненти: набір ідентифікаторів Id, набір примітивних типів PT і кілька операцій. Будувати будемо деякий безліч типів T. По-перше, будь-примітивний тип є і типом Т. Нам знадобиться операція зв'язування ідентифікатора типу, будемо позначати її символом @. Тобто, якщо id — ідентифікатор і t — тип, то lt := t @ id — тип з ідентифікатором. Далі, введемо операцію конструювання на типи з ідентифікаторами: якщо t1, ..., tn — типи з Т і id1, ..., idn — ідентифікатори, то t := (t1 @ id1, t2 @ id2, ..., tn @ idn) — тип з Т. Далі, вводимо операцію «або» на типи: якщо t1, ..., tn — типи Т, то t := t1 | t2 | ... | tn — тип з Т. Така модель спрощено показує структуру ADT.

У нашому випадку примітивними типами виступають, власне, вбудовані типи даних з розряду Int, Byte, Long, String, Double, Boolean і, крім цього, різні типи, які не є частиною ADT, такі як, наприклад, java.time.Instant.

Операція конструювання моделює введення нового типу за допомогою кейс-класів або кейс-обджектов, наприклад, з типів Int, String, String, ідентифікаторів foo bar, baz ми можемо побудувати новий тип Qux case class Qux(foo: Int, bar: String, baz: String), який в нашій моделі буде представлений як Qux := (Int @ foo, String @ bar, String @ baz).

При цьому різні питання з приводу того, які поля включати в цей перелік і які не включати і потрібні додаткові позначки про властивості цих полів, ми можемо сміливо відкидати з тієї простої причини, що всі поля рівноправні і можна буде змінити.

Операція «або» моделює sealed договорі. Обмеженість sealed trait рамками одного файлу дозволяє гарантувати, що перелік типів, які успадковуються від даного sealed trait, повний і відомий на етапі компіляції.

sealed trait PredefColor
case object Red extends PredefColor
case object Green extends PredefColor
case object Blue extends PredefColor

Тобто в цьому випадку ми можемо змоделювати PredefColor := Red|Green|Blue.

При цьому різні зобов'язання по наявності полів у кейс-класах, які успадковують трейты, в цій моделі не враховуються.

Залишається питання про параметричних типах начебто List[T] і т. д., але поки на цьому зупинятися і вводити додатковий формалізм не будемо: більшість питань, пов'язаних з вищими каиндами, вже вирішено в механізмі пошуку имплиситов в компіляторі Scala.

На нашій моделі цілком можна будувати алгоритм структурного порівняння. Отже, нам потрібно порівняти дві структури однакового типу, але потенційно різного значення, і вивести список різних полів. Різні складності порівняння невпорядкованих і впорядкованих колекцій, з якими ми можемо зіткнутися, поки залишимо за кадром і будемо виводити найбільш просте рішення.

Припустимо, у нас є деякий набір операцій порівняння для примітивних типів, тоді нам залишається визначити операцію порівняння для операції конструювання і для операції «чи».

Для операції конструювання ми будемо визначати порівняння як об'єднання відмінності для всіх елементів.

Для операції «або», з урахуванням об'єктів ob1, ob2,...,obn типів t1|...|tn, зрозуміло, що справжні типи цих об'єктів будуть t_i і t_j для деяких i,j 1,...,n. Тоді якщо i == j, ми повертаємо результат порівняння нутрощів об'єктів, а якщо i != j, повертаємо розбіжність дійсних типів.

Інструментарій

Нам знадобляться абстракції для подання операцій @, (_,...,_) і "|". На щастя, бібліотека Shapeless надає такі.

Для операції @ існує окремий тип FieldType[Identifier, T], який являє абстракцію для поля класу, названого деяким ім'ям. Про його використання — нижче.

Для моделювання операції конструювання Shapeless являє гетерогенний список HList. Відмінність гетерогенного списку від звичайного полягає в тому, що цей список зберігає інформацію про тип кожного елемента в типі самого списку.

Справа в тому, що сам тип HList — це надтип для дуже широкого сімейства типів, але конструктора у конкретних типів всього два: HNil — порожній список і ::[+Head, +Tail <: HList]. Завдяки синтаксичним цукру для типів з двома параметрами конструкції на зразок ::[String ::[Int, HNil]] виглядають як String :: Int :: HNil.

HList вміє дуже багато, пов'язане з інформацією про типи. Більш детальну інформацію можна отримати в описі бібліотеки на GitHub .

Нам знадобиться тільки можливість рекурсивно проходити з елементів цього списку.

Далі, операцію «або» представляє тип Coproduct. Він подібний HList в тому, що має структуру, схожу на ланцюг, але на цей раз у нього є кілька конструкторів. Загальний тип Coproduct населений типами T1 :+: T2 :+: ... :+: TN :+: CNil, для N= 1,2,3,.... Кожен тип для фіксованого N= N0 складається з елементів Inl(t1: T1), Inr(Inl(t2: T2), ... , Inr(Inr(,, Inl(tn0: TN0))). Де H : + T при T <: Coproduct Inr означає, що конкретний об'єкт типу H, а Inl означає, що тип конкретного об'єкта знаходиться у T. Тип CNil існує для завершення послідовності CNil, і ні для чого більше.

Рекурсивне проходження по всім можливим варіантам приналежності конкретного об'єкту до типу буде дуже схоже на таке у гетерогенного списку.

Тепер нам необхідно сполучна ланка, яка виведе тип уявлення конкретного об'єкта через описані вище три типи даних і надасть механізм для перетворення з конкретного об'єкта в узагальнене уявлення. У Shapeless існує тайпкласс LabelledGeneric, примірники якого генеруються неявним макросом (як і більшість примірників тайпклассов в цій бібліотеці, так що відкриття порталу вимірювання неявних макросів і заклик реалізацій — це цілком звичайний спосіб взаємодії з нею) на етапі компіляції. Щоб «закликати» LabelledGeneric для типу T в скоупе, в якому оголошено, досить написати:

import shapeless._
val v = LabelledGeneric[T]

Для того щоб закликати примірник тайпкласса для параметра типу T, необхідно вимагати implicit параметр lgen: LabelledGeneric.Aux[T, Repr], і ввести додатковий параметр, який буде нести тип подання Repr.

З чого складатиметься тип Repr? Цей тип буде діяти саме за описаною вище моделі. Для кейс-класів він буде видавати HList з FieldType[FieldName, TypeName], тегированный спеціальним чином, для sealed trait — coproduct.

Слід зазначити, що в IDE виникають певні складнощі із визначенням типів репрезентації labelled generic. Наприклад, для класу

case class Foo(s: String; i: Int, b: Boolean)

...ми отримаємо тип LabelledGeneric[Foo], який, природно, відповідати справжньому не буде, але дасть нам деяке уявлення про те, як це виглядає в реальності.

LabelledGeneric.Aux[
Foo,
 FieldType[String with KeyTag[Symbol Tagged with[{type s}], String], String] ::
 FieldType[Int with KeyTag[Symbol Tagged with[{type i}], Int], Int] ::
 FieldType[Boolean with KeyTag[Symbol Tagged with[{type b}], Boolean], Boolean] ::
HNil
]

Тут страшні типи начебто String with KeyTag[Symbol Tagged with[{type s}], String] — це спосіб бібліотеки Shapeless генерувати унікальні теги на рівні типів полів класів.

Для ієрархії

sealed trait All
case class Foo(s: String; i: Int, b: Boolean) extends All
case class Bar(l: Long, f: Foo) extends All
case class Baz(foos: List[Foo], bars: List[Bar]) extends All

Ми отримаємо вираз:

LabelledGeneric.Aux[
All,
 Bar with KeyTag[Symbol Tagged with[Symbol Tagged with[{type Bar}]], Bar] :+:
 Baz with KeyTag[Symbol Tagged with[Symbol Tagged with[{type Baz}]], Baz] :+:
 Foo with KeyTag[Symbol Tagged with[Symbol Tagged with[{type Foo}]], Foo] :+:
CNil
 ]

І знову, приписки with KeyTag[_, _] відносяться до технічного аспекту shapeless видавати унікальні типи на кожен клас. (Детальніше — Singleton types)

Природно, працювати з цими типами вручну не треба — з ними потрібно працювати за допомогою рекурсії, і це ми розглянемо трохи нижче.

Рішення

Отже, оголосимо інтерфейс і декілька допоміжних класів. ComparisonResult для результату порівняння у вигляді Success і Failure. FailEntry[T] для подання відмінностей. Path для визначення місцезнаходження порівнюваного елемента.

package object genericComparators {

 sealed trait ComparisonResult {
 def append(fe: FailEntry[_]): ComparisonResult

 def &&(that: ComparisonResult): ComparisonResult

 def fails: Chain[FailEntry[_]]

}

 object ComparisonResult {

 case object Success extends ComparisonResult {
 override def append(fe: FailEntry[_]): ComparisonResult = Failure(Chain(fe))

 override def &&(that: ComparisonResult): ComparisonResult = that

 override def fails: Chain[FailEntry[_]] = Chain.empty[FailEntry[_]]

}

 case Failure class(fails: Chain[FailEntry[_]]) extends ComparisonResult {
 override def append(fe: FailEntry[_]): ComparisonResult = Failure(fails :+ fe)

 override def &&(that: ComparisonResult): ComparisonResult = Failure(this.fails ++ that.fails)

}

}

 case class FailEntry[T](path: AdtPath, left: T, right: T)

 object AdtPath {
 val root = AdtPath(Chain.empty[PathElement])
}

 sealed trait PathElement

 case object Root extends PathElement

 case class DownGeneric(generic: String) extends PathElement
 case class DownField(field: Symbol) extends PathElement
 case class DownCoproductElement(coproductType: Symbol) extends PathElement
 case class Primitive(field: String) extends PathElement
 case class DownIterable(index: Long) extends PathElement
 case class DownManual(tag: String) extends PathElement

 case class AdtPath(steps: Chain[PathElement], last: PathElement = Root) {
 def downHlist(fieldName: Symbol): AdtPath =
 last match {
 case DownField(_) => AdtPath(steps, DownField(fieldName))
 case Primitive(_) => throw new RuntimeException(s"should not never happen")
 case _ => AdtPath(steps :+ last, DownField(fieldName))
}

 def downCoproduct(element: Symbol): AdtPath =
 last match {
 case DownCoproductElement(_) => AdtPath(steps, DownCoproductElement(element))
 case Primitive(_) => throw new RuntimeException(s"should not never happen")
 case _ => AdtPath(steps :+ last, DownCoproductElement(element))
}

 def downGeneric(className: String): AdtPath =
 last match {
 case Primitive(_) => throw new RuntimeException(s"should not never happen")
 case _ => AdtPath(steps :+ last, DownGeneric(className))
}

 def downIterable(index: Long): AdtPath = last match {
 case DownIterable(_) => AdtPath(steps, DownIterable(index))
 case Primitive(_) =>throw new RuntimeException(s"should not never happen")
 case _ => AdtPath(steps :+ last, DownIterable(index))
}

 def downManual(manual: String): AdtPath = AdtPath( steps :+ last, DownManual(manual))

 def primitive(primitiveTag: String): AdtPath = AdtPath( steps :+ last, Primitive(primitiveTag))
}
}

Далі, визначаємо інтерфейс нашого тайпкласса і кілька простих реалізацій:

object GenericComparer {
 def compare[T](first: T, second: T)(implicit
 compare: GenericComparer[T]
 ): ComparisonResult =
 compare.apply(first, second)(AdtPath.root, ComparisonResult.Success)

 def instance[U](f: (U, U) => Boolean)(tag: String = ""): GenericComparer[U] = new GenericComparer[U] {
 override def apply(t1: U, t2: U)(implicit path: AdtPath, result: ComparisonResult): ComparisonResult =
 if (f(t1, t2)) result
 else result.append(FailEntry(path, t1, t2))
}
}

trait GenericComparer[T] {
 def apply(t1 T, t2 T)(implicit path: AdtPath, result: ComparisonResult): ComparisonResult
}

Неявний параметр path потрібен для можливості «записувати» пройдений шлях по структурі класу при рекурсивном застосуванні цієї функції. Неявний параметр result є акумулятором рекурсії.

У компанион обджекте створено метод def compare[T](first: T, second: T)(implicit compare: GenericComparer[T]): ComparisonResult, який надає інтерфейс для використання тайпкласса як GenericComparer.compare(a,b).

Там ж функція для створення реалізації функції порівняння instance.

Користуючись цією функцією, можна зробити набір реалізацій для примітивів:

implicit val scompare: GenericComparer[String] = GenericComparer.instance[String] { _ == _ } {"string"}
implicit val icompare: GenericComparer[Int] = GenericComparer.instance[Int] { _ == _ } {"int"}
implicit val lcompare: GenericComparer[Long] = GenericComparer.instance[Long] { _ == _ } {"long"}
implicit val bcompare: GenericComparer[Byte] = GenericComparer.instance[Byte] { _ == _ } {"byte"}
implicit val boolcompare: GenericComparer[Boolean] = GenericComparer.instance[Boolean] { _ == _ } {"bool"}
implicit val ccompare: GenericComparer[Char] = GenericComparer.instance[Char] { _ == _ } {"char"}
implicit val shortcompare: GenericComparer[Short] = GenericComparer.instance[Short] { _ == _ } {"short"}
implicit val bicompare: GenericComparer[BigInt] = GenericComparer.instance[BigInt] { _ == _ } {"bigInt"}
implicit val dcompare: GenericComparer[Double] = GenericComparer.instance[Double] { _ == _ } {"double"}

Далі, приступимо до створення операції для композитного об'єкта, який представимо у вигляді LabeledGeneric.

//decompose sealed trait family
implicit def lgenCompare[T, Repr](
implicit
 ctag: ClassTag[T],
 lgen: LabelledGeneric.Aux[T, Repr],
 reprCompare: Lazy[GenericComparer[Repr]]): GenericComparer[T] = new GenericComparer[T] {
 override def apply(t1 T, t2 T)(implicit path: AdtPath, result: ComparisonResult): ComparisonResult = {
reprCompare.value.apply(
lgen.to(t1),
lgen.to(t2))(
path.downGeneric(ctag.runtimeClass.getSimpleName),
result
)
}
}

Ця реалізація функції тайпкласса намагається закликати LabelledGeneric з яким-небудь типом подання і вивести реалізацію для узагальненого уявлення. Але сама по собі ця реалізація сенсу без реалізацій для примітивів, випадку з HList і Coproduct. Зверніть увагу на те, що операція порівняння для узагальненого уявлення Repr обгорнута у Lazy, для того щоб уникнути помилки diverging implicit expansion.

Отже, спочатку випадок HList.

Термінальна точка для рекурсії:

implicit val hnilCompare: GenericComparer[HNil] =
 GenericComparer.instance[HNil] { (_, _) => true }{"hnil"}

І сама рекурсія, метод який виводить операцію порівняння для непустого списку Head :: Tail:

implicit def hconsCompareKeyed[Key <: Symbol, Head, Tail <: HList](
 implicit key: Witness.Aux[Key],
 compareHeads: GenericComparer[Head],
 compareTails: Lazy[GenericComparer[Tail]]
): GenericComparer[FieldType[Key, Head] :: Tail] =
 new GenericComparer[FieldType[Key, Head] :: Tail] {
 override def apply(
 t1: FieldType[Key, Head] :: Tail,
 t2: FieldType[Key, Head] :: Tail)(
 implicit path: AdtPath, result: ComparisonResult
 ): ComparisonResult = {
 val newPath = path.downHlist(key.value)

 compareHeads.apply(t1.head, t2.head)(newPath, result) &&
 compareTails.value.apply(t1.tail, t2.tail)(newPath, result)
}
}

Звідки такий набір типів і така риса? По-перше, нам необхідно ім'я поля, його дістаємо за допомогою типу Key, який є символом. Для «виклику» значення імені поля використовуємо неявний параметр key: Witness.Aux[Key], який і викличе те значення, яке кодують страшні типи голови вище. Це і є той обіцяний спосіб не працювати безпосередньо з типом тега, який позначає конкретне поле в класі — ввівши його як параметр функції, компілятор спробує вивести його автоматично.

Далі, Head — це тип тегируемого за допомогою Key поля в класі, нам необхідно мати для нього операцію порівняння, тому ми заявляємо це як ще один неявний параметр — compareHeads: GenericComparer[Head].

І нарешті, щоб зробити рекурсивний виклик, ми вводимо параметр типу хвоста списку Tail, який знову має бути гетерогенним списком, і просимо вивести для хвоста реалізацію за допомогою заяви ще одного неявного параметра: compareTails: Lazy[GenericComparer[Tail]].

Чому це буде рекурсивным викликом? Тому що під тип GenericComparer[Tail] підпадає ця ж реалізація, або ж термінальна точка рекурсії hnilCompare. При цьому ми позбулися від необхідності розглядати всі типи списку відразу, а розглядаємо їх тільки по одному за виклик, що дозволяє обробляти списки довільної довжини.

Нутрощі цієї реалізації досить примітивні, основну роботу роблять неявні параметри.

Тепер розглянемо випадок Coproduct. Терминирование рекурсії будемо проводити за допомогою:

implicit val cnilCompare: GenericComparer[CNil] =
 GenericComparer.instance[CNil] { (a, b) => a.impossible
}{"neverhappen"}

Цей метод ніколи не буде викликаний, але необхідний, щоб зупинити пошук неявних параметрів. Тепер безпосередньо реалізація для непустого твори, подібна до тієї, що ми навели для HList.

implicit def cconsCompareKeyed[Key <: Symbol, Head, Tail <: Coproduct](
 implicit key: Witness.Aux[Key],
 compareHeads: GenericComparer[Head],
 compareTails: Lazy[GenericComparer[Tail]]
): GenericComparer[FieldType[Key, Head] :+: Tail] =
 new GenericComparer[FieldType[Key, Head] :+: Tail] {
 override def apply(
 t1: FieldType[Key, Head] :+: Tail,
 t2: FieldType[Key, Head] :+: Tail)(
 implicit path: AdtPath, result: ComparisonResult): ComparisonResult = {
 val newPath = path.downCoproduct(key.value)
 (t1, t2) match {
 case (Inl(a), Inl(b)) =>
 compareHeads.apply(a, b)(newPath, result)
 case (Inl(_), Inr(_)) | (Inr(_), Inl(_)) =>
 result.append(FailEntry(newPath, Coproduct.unsafeGet(t1), Coproduct.unsafeGet(t2)))
 case (Inr(tail1), Inr(tail2)) =>
 compareTails.value.apply(tail1, tail2)
}
}
 }

У цій реалізації ми проходимо по всіх можливих типів об'єктів, які входять в добуток, і для випадку, коли об'єкт збігаються, проводимо порівняння вмісту. Механізм рекурсії та отримання імені конкретного елемента-типу твори — точно такий же, як і у випадку з HList.

Для завершеності наведемо дуже обмежену базову реалізацію порівняння послідовностей (насправді, схрещування алгоритму для диффов послідовностей і множини з ієрархічною структурою нетривіальні і гідні окремої статті, але всі пропозиції по удосконаленню приймаються).

def compareIterables[T: GenericComparer]: GenericComparer[Iterable[T]] =
 new GenericComparer[Iterable[T]] {
 override def apply(t1: Iterable[T], t2: Iterable[T])(implicit path: AdtPath, result: ComparisonResult)
 : ComparisonResult = {
 if (t1.size == t2.size) {
 (t1 zip t2).foldLeft((result, 0)) { case ((res, i), (el1, el2)) =>
 (implicitly[GenericComparer[T]].apply(el1, el2)(path.downIterable(i), res), i + 1)
}._1
}
 else result.append(FailEntry(path.downIterable(-1), t1, t2))
}
}

Об'єднання

Тепер необхідно з'єднати все в єдину структуру, придатну до використання. Нам би хотілося при всьому тому, що вміє ця бібліотека, вміти замінювати генерацію операції своєю реалізацією, інакше б від такої бібліотеки користі було мало. Досягти цього можна за допомогою механізму затінення неявних значень (implicit shadowing). Для цього потрібно покласти реалізацію для Labelled generic, HList і Coproduct в трейт GenericComparatorsLowestPriority. Потім від нього успадковувати трейт AllComparators з усіма реалізаціями для примітивів. Далі — в будь-якій точці застосування успадковувати AllComparators своїм класом/обджектом/трейтом.

При цьому для одного і того ж типу компілятор буде вибирати тільки той имплисит, який знаходиться найменш глибоко в ієрархії успадкування. Тому имплисит для LabelledGeneric буде легко перекриватися вашим власним. При цьому імпортувати значення з GenericComparatorsLowestPriority категорично не можна.

Готовий результат можна подивитися в репозиторії на GitHub .

Найбільш простий спосіб запустити цей код локально — встановити intellij IDEA Community edition і Scala plugin до неї, далі просто відкрити проект і імпортувати — плагін та IDE зроблять все самі.

Переваги і недоліки

У підсумку ми малими зусиллями домоглися способу отримувати операцію структурного порівняння без написання бойлерплейта, при цьому написавши зовсім трохи коду. При додаванні нових класів нам необхідно перевантажувати цю операцію тільки для типів-примітивів.

Основним недоліком поточної реалізації є те, що з'ясування причини, по якій компілятор не може вивести операцію порівняння для чого-небудь, може займати певний час, але опція компілятора «-Xlog-implicits» стане гарною підмогою — пройшовшись за повідомленнями, можна зрозуміти, чого саме не вистачає. Єдина втіха — ви завжди дізнаєтеся про це до запуску програми.

Другий недолік — не сама хороша робота з множинами і колекціями, але це справа поправима.

Післямова

Насправді продемонстрованим способом можна одержувати реалізації для інших тайпклассов, не тільки операції порівняння. Наприклад, для процедури кодування/декодування в різні формати.

Опубліковано: 23/11/19 @ 08:00
Розділ Різне

Рекомендуємо:

Java дайджест #45: Micronaut і Quarkus, відео з Devoxx Belgium 2019
Здоров'я ІТ-спеціаліста: сон, харчування, фізична активність
Certonid — SSH центр сертифікації, який працює на AWS Lambda
Шукаємо причини овертаймів в команді: чек-лист для менеджера
Product Marketing дайджест #1: стратегія Ahrefs, 63 ради щодо збільшення конверсії