Чи можна "викласти розмір у тип" у haskell?


20

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

Наприклад, я хотів би, щоб підпис крапкового продукту був чимось подібним

dotprod :: Num a, VecDim d => Vector a d -> Vector a d -> a

де dтип містить єдине ціле значення (представляє розмірність цих Векторів).

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

Чи, можливо, якийсь кращий / простіший спосіб досягти того самого?


3
Так, якщо я правильно пам’ятаю, в Haskell є бібліотеки, які забезпечують цей базовий рівень залежного введення тексту. Я недостатньо знайомий, хоча щоб дати хорошу відповідь.
Теластин

Озираючись навколо, здається, що tensorбібліотека досягає цього досить елегантно, використовуючи рекурсивне dataвизначення: noaxiom.org/tensor-documentation#ordinals
mitchus

Це scala, не haskell, але в ньому є деякі пов'язані концепції щодо використання залежних типів для запобігання невідповідних розмірів, а також невідповідних "типів" векторів. chrisstucchio.com/blog/2014/…
Daenyth

Відповіді:


32

Щоб розширити відповідь @ KarlBielefeldt, ось повний приклад того, як реалізувати вектори - списки зі статистично відомою кількістю елементів - у Haskell. Тримайся капелюха ...

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE DeriveFoldable #-}
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE DeriveTraversable #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE TypeFamilies #-}

import Prelude hiding (foldr, zipWith)
import qualified Prelude
import Data.Type.Equality
import Data.Foldable
import Data.Traversable

Як видно з довгого списку LANGUAGEдиректив, це працюватиме лише з останньою версією GHC.

Нам потрібен спосіб представлення довжин в системі типів. За визначенням, натуральне число дорівнює нулю ( Z) або є наступником якогось іншого натурального числа ( S n). Так, наприклад, було б написано число 3 S (S (S Z)).

data Nat = Z | S Nat

З розширенням DataKinds , ця dataдекларація являє вид називається Natі два типу конструктори називають Sі Z- іншими словами , ми маємо тип рівня натуральних чисел. Зауважте, що типи Sі Zне мають жодних значень-членів - лише типи роду *населені значеннями.

Тепер ми представляємо GADT, що представляє вектори з відомою довжиною. Зверніть увагу на підпис роду: Vecпотрібен тип родуNat (тобто a Zабо Sтип) для відображення його довжини.

data Vec :: Nat -> * -> * where
    VNil :: Vec Z a
    VCons :: a -> Vec n a -> Vec (S n) a
deriving instance (Show a) => Show (Vec n a)
deriving instance Functor (Vec n)
deriving instance Foldable (Vec n)
deriving instance Traversable (Vec n)

Визначення векторів схоже на визначення зв'язаних списків з додатковою інформацією на рівні про його довжину. Вектор є або VNilв цьому випадку він має довжину Z(ero), або це VConsклітина, що додає елемент до іншого вектора, і в цьому випадку його довжина на одну більше, ніж інший вектор ( S n). Зауважте, що немає аргументу конструктора типу n. Він просто використовується під час компіляції для відстеження довжин і буде стертий до того, як компілятор генерує машинний код.

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

ghci> :t (VCons 'a' (VCons 'b' VNil))
(VCons 'a' (VCons 'b' VNil)) :: Vec ('S ('S 'Z)) Char  -- (S (S Z)) means 2
ghci> :t (VCons 13 (VCons 11 (VCons 3 VNil)))
(VCons 13 (VCons 11 (VCons 3 VNil))) :: Num a => Vec ('S ('S ('S 'Z))) a  -- (S (S (S Z))) means 3

Точковий продукт діє так само, як і для списку:

-- note that the two Vec arguments are declared to have the same length
vap :: Vec n (a -> b) -> Vec n a -> Vec n b
vap VNil VNil = VNil
vap (VCons f fs) (VCons x xs) = VCons (f x) (vap fs xs)

zipWith :: (a -> b -> c) -> Vec n a -> Vec n b -> Vec n c
zipWith f xs ys = fmap f xs `vap` ys

dot :: Num a => Vec n a -> Vec n a -> a
dot xs ys = foldr (+) 0 $ zipWith (*) xs ys

vap, який 'zippily' застосовує вектор функцій до вектора аргументів, є Vecдодатково <*>; Я не ставлю це в Applicativeекземпляр, тому що він стає безладним . Зауважте також, що я використовую foldrекземпляр, створений компілятором Foldable.

Давайте спробуємо:

ghci> let v1 = VCons 2 (VCons 1 VNil)
ghci> let v2 = VCons 4 (VCons 5 VNil)
ghci> v1 `dot` v2
13
ghci> let v3 = VCons 8 (VCons 6 (VCons 1 VNil))
ghci> v1 `dot` v3
<interactive>:20:10:
    Couldn't match type ‘'S 'Z’ with ‘'Z’
    Expected type: Vec ('S ('S 'Z)) a
      Actual type: Vec ('S ('S ('S 'Z))) a
    In the second argument of ‘dot’, namely ‘v3’
    In the expression: v1 `dot` v3

Чудово! Ви отримуєте помилку часу компіляції при спробі dotвекторів, довжина яких не збігається.


Ось спроба функції об'єднати вектори разом:

-- This won't compile because the type checker can't deduce the length of the returned vector
-- VNil +++ ys = ys
-- (VCons x xs) +++ ys = VCons x (concat xs ys)

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

type family (n :: Nat) :+: (m :: Nat) :: Nat where
    Z :+: m = m
    (S n) :+: m = S (n :+: m)

Ця type familyдекларація вводить функцію на типи, що називаються :+:- іншими словами, це рецепт перевірки типу для обчислення суми двох натуральних чисел. Він визначений рекурсивно - коли лівий операнд більший за Zеро, ми додаємо його до виводу та зменшуємо його на одиницю в рекурсивному виклику. (Це гарна вправа, щоб написати функцію типу, яка помножує два Natс.) Тепер ми можемо зробити +++компіляцію:

infixr 5 +++
(+++) :: Vec n a -> Vec m a -> Vec (n :+: m) a
VNil +++ ys = ys
(VCons x xs) +++ ys = VCons x (concat xs ys)

Ось як ви його використовуєте:

ghci> VCons 1 (VCons 2 VNil) +++ VCons 3 (VCons 4 VNil)
VCons 1 (VCons 2 (VCons 3 (VCons 4 VNil)))

Поки так просто. Що робити, коли ми хочемо зробити протилежне конкатенації і розділити вектор на два? Довжина вихідних векторів залежить від значення часу виконання аргументів. Ми хотіли б написати щось подібне:

-- this won't work because there aren't any values of type `S` and `Z`
-- split :: (n :: Nat) -> Vec (n :+: m) a -> (Vec n a, Vec m a)

але, на жаль, Хаскелл не дозволить нам це зробити. Допущення значення цього nаргументу з'являтися в типі повернення (це зазвичай називається залежною функція або типу пі ) вимагає "повного спектра» залежних типів, в той час як DataKindsтільки дає нам сприяли конструкторам типів. Інакше кажучи, тип конструкторів Sі Zне відображається на рівні значень. Нам доведеться погодитись на значення одиночних значень для представлення певного періоду часу Nat. *

data Natty (n :: Nat) where
    Zy :: Natty Z  -- pronounced 'zed-y'
    Sy :: Natty n -> Natty (S n)  -- pronounced 'ess-y'
deriving instance Show (Natty n)

Для даного типу n(з родом Nat) існує точно один термін типу Natty n. Ми можемо використовувати одиночне значення в якості свідчення часу для n: навчання про Nattyвчить нас про його nі навпаки.

split :: Natty n ->
         Vec (n :+: m) a ->  -- the input Vec has to be at least as long as the input Natty
         (Vec n a, Vec m a)
split Zy xs = (Nil, xs)
split (Sy n) (Cons x xs) = let (ys, zs) = split n xs
                           in (Cons x ys, zs)

Давайте візьмемо це за спину:

ghci> split (Sy (Sy Zy)) (VCons 1 (VCons 2 (VCons 3 VNil)))
(VCons 1 (VCons 2 VNil), VCons 3 VNil)
ghci> split (Sy (Sy Zy)) (VCons 3 VNil)
<interactive>:116:21:
    Couldn't match type ‘'S ('Z :+: m)’ with ‘'Z’
    Expected type: Vec ('S ('S 'Z) :+: m) a
      Actual type: Vec ('S 'Z) a
    Relevant bindings include
      it :: (Vec ('S ('S 'Z)) a, Vec m a) (bound at <interactive>:116:1)
    In the second argument of ‘split’, namely ‘(VCons 3 VNil)’
    In the expression: split (Sy (Sy Zy)) (VCons 3 VNil)

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

* singletonsБібліотека містить кілька помічників шаблону Haskell для генерування однозначних значень, як Nattyдля вас.


Останній приклад. А як бути, коли ти статично не знаєш розмірність вектора? Наприклад, що робити, якщо ми намагаємося побудувати вектор із даних про час виконання у формі списку? Вам потрібен тип вектора залежить від довжини списку введення. Інакше кажучи, ми не можемо використовувати foldr VCons VNilдля побудови вектора, оскільки тип вихідного вектора змінюється з кожною ітерацією складки. Нам потрібно зберегти довжину вектора в таємниці від компілятора.

data AVec a = forall n. AVec (Natty n) (Vec n a)
deriving instance (Show a) => Show (AVec a)

fromList :: [a] -> AVec a
fromList = Prelude.foldr cons nil
    where cons x (AVec n xs) = AVec (Sy n) (VCons x xs)
          nil = AVec Zy VNil

AVec- це екзистенційний тип : змінна типу nне відображається у типі повернення AVecконструктора даних. Ми використовуємо його для імітації залежної пари : fromListне можемо статично визначити довжину вектора, але він може повернути те, на що ти можеш узгодити шаблон, щоб дізнатися довжину вектора - Natty nу першому елементі кортежу . Як Конор МакБрайд висловлюється у відповідній відповіді , "Ви дивитесь на одне, і роблячи це, дізнаєтесь про інше".

Це звичайна методика для екзистенційно кількісних типів. Оскільки ви фактично нічого не можете зробити з даними, для яких ви не знаєте тип - спробуйте написати функцію data Something = forall a. Sth a- екзистенціали часто поставляються в комплекті з доказами GADT, що дозволяє відновити вихідний тип, виконавши тести на відповідність шаблону. Інші поширені шаблони для екзистенціалів включають функції упаковки для обробки вашого типу ( data AWayToGetTo b = forall a. HeresHow a (a -> b)), що є акуратним способом виконання модулів першого класу, або вбудований словник класу типів ( data AnOrd = forall a. Ord a => AnOrd a), який може допомогти імітувати поліморфізм підтипу.

ghci> fromList [1,2,3]
AVec (Sy (Sy (Sy Zy))) (VCons 1 (VCons 2 (VCons 3 Nil)))

Залежні пари корисні, коли статичні властивості даних залежать від динамічної інформації, недоступної під час компіляції. Ось filterдля векторів:

filter :: (a -> Bool) -> Vec n a -> AVec a
filter f = foldr (\x (AVec n xs) -> if f x
                                    then AVec (Sy n) (VCons x xs)
                                    else AVec n xs) (AVec Zy VNil) 

За dotдві AVecсекунди нам потрібно довести GHC, що їх довжини рівні. Data.Type.Equalityвизначає GADT, який може бути побудований лише тоді, коли аргументи його типу однакові:

data (a :: k) :~: (b :: k) where
    Refl :: a :~: a  -- short for 'reflexivity'

Коли ви узгоджуєте схему Refl, GHC це знає a ~ b. Існує також кілька функцій, які допоможуть вам працювати з цим типом: ми будемо використовувати gcastWithдля перетворення між еквівалентними типами та TestEqualityвизначати, чи рівні два Nattys.

Щоб перевірити рівність двох Nattyз, ми будемо повинні використовувати той факт , що якщо два числа рівні, то їх наступники також рівні ( :~:це конгруентно більш S):

congSuc :: (n :~: m) -> (S n :~: S m)
congSuc Refl = Refl

Збірка шаблону на Reflлівій стороні повідомляє GHC про це n ~ m. Маючи це знання, це неприємно S n ~ S m, тому GHC дозволяє нам повернути нове Reflвідразу.

Тепер ми можемо написати екземпляр TestEqualityпрямої рекурсії. Якщо обидва числа дорівнюють нулю, вони рівні. Якщо обидва числа мають попередників, вони рівні, якщо попередники рівні. (Якщо вони не рівні, просто поверніться Nothing.)

instance TestEquality Natty where
    -- testEquality :: Natty n -> Natty m -> Maybe (n :~: m)
    testEquality Zy Zy = Just Refl
    testEquality (Sy n) (Sy m) = fmap congSuc (testEquality n m)  -- check whether the predecessors are equal, then make use of congruence
    testEquality Zy _ = Nothing
    testEquality _ Zy = Nothing

Тепер ми можемо скласти шматки до dotпари AVecs невідомої довжини.

dot' :: Num a => AVec a -> AVec a -> Maybe a
dot' (AVec n u) (AVec m v) = fmap (\proof -> gcastWith proof (dot u v)) (testEquality n m)

По-перше, узор узгоджується на AVecконструкторі, щоб витягнути уявлення про тривалість векторів. Тепер використовуйте testEqualityдля визначення того, чи рівні ці довжини. Якщо вони є, у нас буде Just Refl; gcastWithвикористовуватиме цей доказ рівності для того, щоб переконатися, що dot u vвін добре набраний, виконуючи його неявне n ~ mприпущення.

ghci> let v1 = fromList [1,2,3]
ghci> let v2 = fromList [4,5,6]
ghci> let v3 = fromList [7,8]
ghci> dot' v1 v2
Just 32
ghci> dot' v1 v3
Nothing  -- they weren't the same length

Зауважте, що оскільки вектор без статичних знань про його довжину в основному є списком, ми фактично повторно реалізували версію списку dot :: Num a => [a] -> [a] -> Maybe a. Різниця полягає в тому, що ця версія реалізована з точки зору векторів dot. Ось сенс: перед тим, як перевірка типу дозволить вам телефонувати dot, ви, мабуть, перевірили, чи списки вводу мають однакову довжину testEquality. Я схильний отримувати ifповідомлення невірним способом, але не в залежності від набору!

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

Оскільки Nothingце не дуже інформативно, ви можете додатково уточнити тип dot'повернення доказів того, що довжини не рівні (у вигляді доказів, що їх різниця не дорівнює 0) у випадку відмови. Це досить схоже на стандартну техніку Haskell, Either String aщоб можливо повернути повідомлення про помилку, хоча термін підтвердження набагато корисніший у порівнянні з рядком!


Таким чином, закінчується ця екскурсія по свистках деяких методів, які є поширеними в програмах Haskell залежно від типу. Програмування подібних типів у Haskell - це справді класно, але при цьому дуже незручно. Розбиття всіх ваших залежних даних на безліч представлень, що означають одне і те ж - Natтип, Natвид, Natty nсинглтон - насправді досить громіздкий, незважаючи на існування генераторів коду, які допоможуть на котлопластіні. В даний час існують обмеження щодо того, що можна підвищити до рівня типу. Це все ж мучить! Розум роздумує над можливостями - в літературі є приклади в Haskell сильно типізованих printf, інтерфейсів бази даних, двигунів компонування інтерфейсу ...

Якщо ви хочете трохи прочитати, є все більша кількість літератури про залежно введені Haskell, як опубліковані, так і на таких сайтах, як Stack Overflow. Хорошим початковим пунктом є папір про хасохізм - ця стаття подає цей самий приклад (серед інших), дещо детально обговорюючи хворобливі частини. Сінглтон папір демонструє техніку значень одноелементні (наприклад, ). Для отримання додаткової інформації про залежне введення тексту загалом, підручник з Agda - це гарне місце для початку; Крім того, Idris - це мова в розробці, яка (орієнтовно) розроблена як "Haskell із залежними типами".Natty


@Benjamin FYI, посилання на Ідріс наприкінці, здається, порушено.
Ерік Ейдт

@ErikEidt На жаль, дякую, що вказав на це! Я його оновлю.
Бенджамін Ходжсон

14

Це називається залежним введенням тексту . Коли ви дізнаєтесь ім'я, ви зможете знайти більше інформації про нього, ніж ви коли-небудь могли сподіватися. Існує також цікава мова, схожа на хешкелл, що називається Idris, яка використовує їх на самому собі. Її автор зробив кілька справді хороших презентацій на тему, яку ви можете знайти на youtube.


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

4
@DeadMG Навпаки, залежна типізація говорить про значення під час компіляції . Типи під час виконання - це відображення, не залежне від набору тексту. Як видно з моєї відповіді, розмір випічки у тип далеко не простий для загального виміру. (Ви можете визначити newtype Vec2 a = V2 (a,a), newtype Vec3 a = V3 (a,a,a)і так далі , але це не те , що питання не просить.)
Бенджамін Ходжсон

Ну, значення з'являються лише під час виконання, тому ви не можете реально говорити про значення під час компіляції, якщо не хочете вирішити проблему зупинки. Все, що я говорю, це те, що навіть у C ++ ви можете просто шаблонувати розмірність, і це добре працює. Хіба це не має еквівалента в Haskell?
DeadMG

4
@DeadMG Мови, що залежать від типу "повного спектру" (наприклад, Agda), насправді дозволяють довільно обчислювати терміни на мові типу. Як ви зазначаєте, це ставить під загрозу спробу вирішити проблему зупинки. Більшість систем, які набирають залежність, afaik, розглядають цю проблему, не закінчуючи Тьюрінга . Я не хлопець C ++, але мене не дивує, що ви можете імітувати залежні типи за допомогою шаблонів; шаблонами можна зловживати різними творчими способами.
Бенджамін Ходжсон

4
@BenjaminHodgson Ви не можете робити залежні типи за допомогою шаблонів, оскільки ви не можете імітувати тип pi. «Канонічний» залежний тип повинен вимагати б вам потрібно , це Pi (x : A). Bщо є функцією від Aдо , B xде xаргумент функції. Тут тип повернення функції залежить від виразу, поданого як аргумент. Однак все це можна стерти, це лише час компіляції
Даніель Гратцер
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.