5 кроків створення вашого першого класу типу Scala

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

Фото Стенлі Дая на знімку

Тип класу - це модель, що бере свій початок від Хаскелла, і це його стандартний спосіб здійснення поліморфізму. Цей тип поліморфізму називається спеціальним поліморфізмом. Його назва походить від того, що всупереч відомій підтиповому поліморфізму ми можемо розширити певну функціональність бібліотеки, навіть не маючи доступу до вихідного коду бібліотеки та класу, до якого функціонування ми хочемо розширити.

У цьому дописі ви побачите, що використання класів типів може бути настільки ж зручним, як і використання звичайного поліморфізму OOP. Вміст, наведений нижче, проведе вас через всі етапи впровадження шаблону типу класу, щоб допомогти вам краще зрозуміти внутрішні бібліотеки функціонального програмування.

Створення першого класу

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

Як приклад у цій статті я буду використовувати клас Eq Type з бібліотеки котів.

Eq [A] {
  def єEquals (a: A, b: A): булева
}

Тип класу Eq [A] - це договір про можливість перевірити, чи два об'єкти типу A рівні на основі деяких критеріїв, реалізованих у методі areEquals.

Створити екземпляр нашого типу класу так само просто, як і миттєвий клас, який розширює згадану ознаку лише з однією різницею, що екземпляр нашого типу буде доступний як неявний об'єкт.

def moduloEq (дільник: Int): Eq [Int] = новий Eq [Int] {
 override def areEquals (a: Int, b: Int) = дільник% = = b b дільник
}
неявна val modulo5Eq: Eq [Int] = moduloEq (5)

Наведений фрагмент коду можна трохи ущільнити у наступній формі.

def moduloEq: Eq [Int] = (a: Int, b: Int) => a% 5 == b% 5

Але зачекайте, як ви можете призначити функцію (Int, Int) => булева для посилання на тип Eq [Int] ?! Ця річ можлива завдяки функції Java 8 під назвою Інтерфейс типу Single Single Method. Ми можемо зробити таке, коли у нас є лише один абстрактний метод.

Тип роздільної здатності

У цьому пункті я покажу вам, як використовувати екземпляри класу типу та як магічно зв’язати клас типу Eq [A] з відповідним об'єктом типу A, коли це буде необхідно.

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

def pairEquals [A] (a: A, b: A) (неявне eq: Eq [A]): ​​Варіант [(A, A)] = {
 if (eq.areEquals (a, b)) Some ((a, b)) else None
}

Ми параметризували функціональну паруEquals, щоб працювати з будь-якими типами, забезпечуючи екземпляр класу Eq [A], доступний в їх неявній області застосування.

Коли компілятор не знайде жодного екземпляра, який відповідає вищевказаній декларації, це закінчиться попередженням про помилку компіляції про відсутність належного примірника в наданому неявному обсязі.
  1. Компілятор визначить тип наданих параметрів, застосувавши аргументи до нашої функції та призначивши її псевдонімом A.
  2. Попередній аргумент eq: Eq [A] з неявним ключовим словом призведе до пошуку об’єкта типу Eq [A] у неявній області застосування.

Завдяки імпліцитам та набраним параметрам компілятор може зв’язати клас із відповідним екземпляром класу типу.

Усі екземпляри та функції визначені, давайте перевіримо, чи дає наш код дійсні результати

пара рівностей (2,7)
res0: Варіант [(Int, Int)] = Деякі ((2,7))
пара рівностей (2,3)
res0: Варіант [(Int, Int)] = Немає

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

Контекстні межі

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

Контекстний зв'язок - це оголошення в списку параметрів типу, який синтаксис A: Eq говорить, що кожен тип, що використовується як аргумент функції pairEquals, повинен мати неявне значення типу Eq [A] в неявній області.

def pairEquals [A: Eq] (a: A, b: A): Варіант [(A, A)] = {
 якщо (неявно [Eq [A]]. areEquals (a, b)) Some ((a, b)) else None
}

Як ви помітили, у нас не було посилань, що вказували б на неявну цінність. Для подолання цієї проблеми ми використовуємо функцію неявно [F [_]], яка тягне знайдене неявне значення, вказуючи, до якого типу ми відносимось.

Ось що пропонує нам мова Scala, щоб зробити її все більш стислою. Це все ще виглядає недостатньо добре для мене. Контекстний зв'язок - це дійсно крутий синтаксичний цукор, але це неявно, здається, забруднює наш код. Я зроблю приємний трюк, як подолати цю проблему та зменшити багатослівність нашої реалізації.

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

об'єкт Eq {
 def застосовується [A] (неявна eq: Eq [A]): ​​Eq [A] = eq
}

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

def pairEquals [A: Eq] (a: A, b: A): Варіант [(A, A)] = {
 if (Eq [A] .areEquals (a, b)) Some ((a, b)) else None
}

Неявні перетворення - ака. Синтаксичний модуль

Наступне, що я хочу потрапити на свій робочий стіл - це Eq [A] .areEquals (a, b). Цей синтаксис виглядає дуже багатослівним, оскільки ми прямо посилаємось на екземпляр класу типу, який повинен бути неявним, правда? Друга річ - це те, що наш екземпляр класу типу діє як Service (у значенні DDD) замість реального розширення класу A. На щастя, це також можна виправити за допомогою іншого корисного використання неявного ключового слова.

Що ми тут будемо робити - це надання так званого синтаксису або (ops, як у деяких бібліотеках FP) модулем за допомогою неявних перетворень, що дозволяє нам розширювати API деякого класу без зміни його вихідного коду.

неявний клас EqSyntax [A: Eq] (a: A) {
 def === (b: A): Boolean = Eq [A] .areEquals (a, b)
}

Цей код повідомляє компілятору перетворити клас A, що має екземпляр класу типу Eq [A], до класу EqSyntax, який має одну функцію ===. Все це створює враження, що ми додали функцію === до класу A без зміни вихідного коду.

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

Тепер нам дозволяється застосовувати метод === до типу A щоразу, коли у нас є клас EqSyntax. Тепер наша реалізація pairEquals трохи зміниться і буде наступною.

def pairEquals [A: Eq] (a: A, b: A): Варіант [(A, A)] = {
 if (a === b) Деякі ((a, b)) else None
}

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

Неявна сфера застосування

Як ви бачите, типи класів у Scala суворо залежать від використання неявної функції, тому важливо зрозуміти, як працювати з неявною сферою.

Неявна область дії - це область, в якій компілятор буде шукати неявні екземпляри. Є багато варіантів, тому виникла потреба визначити порядок, у якому шукаються екземпляри. Порядок такий:

1. Локальні та успадковані екземпляри
2. Імпортні екземпляри
3. Визначення з супутнього об'єкта класу типу або параметрів

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

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

відсортовано [B>: A] (неявний порядок: математика. Замовлення [B]): список [A]

Екземпляр класу типу буде шуканий у:
 * Замовлення супутнього об’єкта
 * Список об’єкта-супутника
 * B супутній об'єкт (який також може бути супутнім об'єктом через існування визначення нижчих меж)

Simulacrum

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

Єдина зміна, яку нам слід ввести - це анотація @typeclass, яка є позначкою макросів для розширення нашого синтаксичного модуля.

імпортувати симулякр._
Характер @typeclass Eq [A] {
 @op ("===") def єEquals (a: A, b: A): логічне
}

Інші частини нашої реалізації не потребують змін. Це все. Тепер ви знаєте, як самостійно реалізувати шаблон класу типу в Scala, і я сподіваюся, що ви зрозуміли, як працюють бібліотеки як Simulacrum.

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