Огляд
Програмування на рівні типу має багато подібності з традиційним програмуванням на рівні цінності. Однак, на відміну від програмування на рівні значень, де обчислення відбуваються під час виконання, у програмуванні на рівні типу, обчислення відбуваються під час компіляції. Я спробую провести паралелі між програмуванням на рівні значення та програмуванням на рівні типу.
Парадигми
У програмуванні на рівні типу є дві основні парадигми: "об'єктно-орієнтована" та "функціональна". Більшість прикладів, пов'язаних звідси, випливають з об'єктно-орієнтованої парадигми.
Хороший, досить простий приклад програмування на рівні типу в об'єктно-орієнтованій парадигмі можна знайти в реалізації апокаліпсом зчитування лямбда , що повторюється тут:
// Abstract trait
trait Lambda {
type subst[U <: Lambda] <: Lambda
type apply[U <: Lambda] <: Lambda
type eval <: Lambda
}
// Implementations
trait App[S <: Lambda, T <: Lambda] extends Lambda {
type subst[U <: Lambda] = App[S#subst[U], T#subst[U]]
type apply[U] = Nothing
type eval = S#eval#apply[T]
}
trait Lam[T <: Lambda] extends Lambda {
type subst[U <: Lambda] = Lam[T]
type apply[U <: Lambda] = T#subst[U]#eval
type eval = Lam[T]
}
trait X extends Lambda {
type subst[U <: Lambda] = U
type apply[U] = Lambda
type eval = X
}
Як видно з прикладу, об'єктно-орієнтована парадигма програмування на рівні типу триває наступним чином:
- По-перше: визначте абстрактну ознаку з різними полями абстрактного типу (див. Нижче, що таке абстрактне поле). Це шаблон для гарантування того, що поля певних типів існують у всіх реалізаціях без примусової реалізації. У прикладі лямбда - обчисленні це відповідає ,
trait Lambda
що гарантує , що такі типи існують: subst
, apply
і eval
.
- Далі: визначте підрядки, які розширюють абстрактну ознаку та реалізують різні поля абстрактного типу
- Часто ці підрахунки будуть параметризовані аргументами. У прикладі обчислення лямбда, підтипи -
trait App extends Lambda
це параметризовані два типи ( S
і T
обидва повинні бути підтипами Lambda
), trait Lam extends Lambda
параметризовані одним типом ( T
) та trait X extends Lambda
(які не параметризовані).
- поля типів часто реалізуються за допомогою посилань на параметри типу підручного зображення та іноді посилання їх полів типу через хеш-оператор:
#
(що дуже схоже на оператор крапки: .
за значеннями). В межах App
прикладу лямбда - обчислення, тип eval
реалізується в такий спосіб : type eval = S#eval#apply[T]
. Це, по суті, виклик eval
типу параметра ознаки S
та виклик apply
параметра T
з результатом. Зауважте, S
гарантовано мати eval
тип, оскільки параметр визначає його як підтип Lambda
. Аналогічно, результат eval
повинен мати apply
тип, оскільки він визначений як підтип Lambda
, як зазначено в абстрактній ознаці Lambda
.
Функціональна парадигма складається з визначення безлічі конструкторів параметризованого типу, які не згруповані в ознаки.
Порівняння між програмуванням на рівні значення та програмуванням на рівні типу
- абстрактний клас
- рівень цінності:
abstract class C { val x }
- тип типу:
trait C { type X }
- типи, залежні від шляху
C.x
(посилання значення / функції x в об'єкті C)
C#x
(посилання типу x у ознаці C)
- підпис функції (без реалізації)
- рівень цінності:
def f(x:X) : Y
- type-level:
type f[x <: X] <: Y
(це називається "конструктор типу" і зазвичай виникає в абстрактній ознаці)
- реалізація функції
- рівень цінності:
def f(x:X) : Y = x
- тип типу:
type f[x <: X] = x
- умовні
- перевірка рівності
- рівень цінності:
a:A == b:B
- тип типу:
implicitly[A =:= B]
- рівень значення: трапляється в JVM через тест одиниці під час виконання (тобто немає помилок виконання):
- по суті є твердженням:
assert(a == b)
- type-level: трапляється в компіляторі за допомогою перевірки типу (тобто немає помилок компілятора):
- по суті є типовим порівнянням: напр
implicitly[A =:= B]
A <:< B
, компілюється лише якщо A
є підтипомB
A =:= B
, компілюється лише якщо A
є підтипом B
і B
є підтипомA
A <%< B
, ("видимий як") компілюється лише в тому випадку, якщо A
він переглядається як B
(тобто є неявна конверсія з A
підтипу B
)
- приклад
- більше операторів порівняння
Перетворення між типами та значеннями
У багатьох прикладах типи, визначені за допомогою ознак, часто є як абстрактними, так і запечатаними, і тому їх не можна ні встановити безпосередньо, ні через анонімний підклас. Тож прийнято використовувати null
як значення заповнювача, виконуючи обчислення рівня значень, використовуючи певний тип інтересу:
- наприклад
val x:A = null
, де A
тип, який ви хвилюєте
Через стирання типу параметризовані типи виглядають однаково. Крім того, (як згадувалося вище) значення, з якими ви працюєте, мають тенденцію до всіх null
, і тому кондиціонування типу об'єкта (наприклад, через заяву відповідності) малоефективне.
Хитрість полягає у використанні неявних функцій та значень. Базовий випадок, як правило, є неявним значенням, а рекурсивний випадок зазвичай є неявною функцією. Дійсно, програмування на рівні типу широко використовує імпліцити.
Розглянемо цей приклад ( взято з метаскали та апокаліпсу ):
sealed trait Nat
sealed trait _0 extends Nat
sealed trait Succ[N <: Nat] extends Nat
Тут у вас є піано-кодування натуральних чисел. Тобто у вас є тип для кожного невід’ємного цілого числа: спеціальний тип для 0, а саме _0
; і кожне ціле число, що перевищує нуль, має тип форми Succ[A]
, де A
тип, що представляє менше ціле число. Наприклад, тип, що представляє 2, буде: Succ[Succ[_0]]
(наступник застосовується двічі до типу, що представляє нуль).
Ми можемо псевдоніми різних натуральних чисел для більш зручного ознайомлення. Приклад:
type _3 = Succ[Succ[Succ[_0]]]
(Це дуже схоже на визначення значення a val
для результату функції.)
Тепер, припустимо, ми хочемо визначити функцію рівня значення, def toInt[T <: Nat](v : T)
яка приймає значення аргументу v
, яка відповідає Nat
і повертає ціле число, що представляє натуральне число, закодоване у v
тип. Наприклад, якщо у нас є значення val x:_3 = null
( null
type Succ[Succ[Succ[_0]]]
), ми хочемо toInt(x)
повернути 3
.
Для реалізації toInt
ми використаємо наступний клас:
class TypeToValue[T, VT](value : VT) { def getValue() = value }
Як ми побачимо нижче, буде об’єкт, побудований з класу TypeToValue
для кожного Nat
з _0
до (наприклад) _3
, і кожен буде зберігати представлення значення відповідного типу (тобто TypeToValue[_0, Int]
буде зберігати значення 0
, TypeToValue[Succ[_0], Int]
буде зберігати значення 1
тощо). Примітка, TypeToValue
параметризується двома типами: T
і VT
. T
відповідає типу, якому ми намагаємося призначити значення (у нашому прикладі Nat
) та VT
відповідає типу значення, яке ми йому присвоюємо (у нашому прикладі Int
).
Тепер ми робимо наступні два неявні визначення:
implicit val _0ToInt = new TypeToValue[_0, Int](0)
implicit def succToInt[P <: Nat](implicit v : TypeToValue[P, Int]) =
new TypeToValue[Succ[P], Int](1 + v.getValue())
І ми реалізуємо toInt
так:
def toInt[T <: Nat](v : T)(implicit ttv : TypeToValue[T, Int]) : Int = ttv.getValue()
Щоб зрозуміти, як toInt
працює, давайте розглянемо, що це робиться на пару входів:
val z:_0 = null
val y:Succ[_0] = null
Коли ми телефонуємо toInt(z)
, компілятор шукає неявний аргумент ttv
типу TypeToValue[_0, Int]
(оскільки z
є типу _0
). Він знаходить об’єкт _0ToInt
, викликає getValue
метод цього об'єкта і повертається назад 0
. Важливим моментом є те, що ми не вказали програмі, який об’єкт використовувати, компілятор знайшов це неявно.
Тепер розглянемо toInt(y)
. Цього разу компілятор шукає неявний аргумент ttv
типу TypeToValue[Succ[_0], Int]
(оскільки y
є типу Succ[_0]
). Він знаходить функцію succToInt
, яка може повернути об'єкт відповідного типу ( TypeToValue[Succ[_0], Int]
) та оцінити його. Сама ця функція приймає неявний аргумент ( v
) типу TypeToValue[_0, Int]
(тобто, TypeToValue
де параметр першого типу має одну меншу кількість Succ[_]
). Компілятор постачає _0ToInt
(як це було зроблено при оцінці toInt(z)
вище) і succToInt
будує новий TypeToValue
об'єкт зі значенням 1
. Знову ж таки, важливо зазначити, що компілятор надає всі ці значення неявно, оскільки ми не маємо до них явного доступу.
Перевірка вашої роботи
Існує кілька способів перевірити, чи обчислення на рівні типу роблять те, що ви очікуєте. Ось кілька підходів. Зробіть два типи, A
і B
щоб ви хочете перевірити, вони рівні. Потім перевірте, чи складається наступне:
Equal[A, B]
implicitly[A =:= B]
Крім того, ви можете перетворити тип у значення (як показано вище) і зробити перевірку значень часу виконання. Наприклад assert(toInt(a) == toInt(b))
, де a
є тип A
і b
є тип B
.
Додаткові ресурси
Повний набір доступних конструкцій можна знайти в розділі про типи довідкового посібника зі шкалою (pdf) .
Adriaan Moors має декілька наукових праць про конструктори типів та пов'язані з ними теми із прикладами шкали:
Apocalisp - це блог з багатьма прикладами програмування на рівні в масштабі.
ScalaZ - це дуже активний проект, який забезпечує функціональність, яка розширює API Scala, використовуючи різні функції програмування на рівні типу. Це дуже цікавий проект, який має великі результати.
MetaScala - це бібліотека на рівні типу Scala, включаючи метатипи для натуральних чисел, булевих одиниць, одиниць, HList тощо. Це проект Jesper Nordenberg (його блог) .
У Michid (блозі) є кілька приголомшливих прикладів програмування на рівні в Scala (з іншого відповіді):
У Debasish Ghosh (блозі) також є відповідні публікації:
(Я робив деякі дослідження з цього приводу, і ось що я дізнався. Я все ще новачок у ньому, тому, будь ласка, вкажіть будь-які неточності у цій відповіді.)