Можно ли «запечь измерение в тип» в haskell?

20

Предположим, я хочу написать библиотеку, которая работает с векторами и матрицами. Можно ли объединить измерения в типы, чтобы операции несовместимых измерений вызывали ошибку во время компиляции?

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

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

где dтип содержит одно целочисленное значение (представляющее размерность этих векторов).

Я полагаю, что это можно сделать путем определения (вручную) отдельного типа для каждого целого числа и сгруппировать их в класс типов с именем VecDim. Есть ли какой-то механизм для «генерации» таких типов?

Или, возможно, какой-то лучший / более простой способ достижения того же самого?

mitchus
источник
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(эро), либо это VConsячейка, добавляющая элемент в другой вектор, и в этом случае его длина на один больше, чем у другого вектора ( S n). Обратите внимание, что нет аргумента конструктора типа n. Он просто используется во время компиляции для отслеживания длины и будет удален до того, как компилятор сгенерирует машинный код.

Мы определили тип вектора, который несет статическое знание его длины. Давайте запросим тип нескольких Vecs, чтобы понять, как они работают:

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Библиотека содержит несколько помощников Template 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-свидетельством, которое позволяет вам восстановить исходный тип, выполнив тесты на соответствие шаблону. Другие распространенные шаблоны для экзистенциалов включают в себя упаковку функций для обработки вашего type ( 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для определения, Nattyравны ли два s.

Чтобы проверить равенство двух 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пару AVecс неизвестной длины.

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. Хорошей отправной точкой является статья о хасохизме - статья проходит этот самый пример (среди прочего), обсуждая некоторые болезненные детали. В статье Singletons демонстрируется техника одноэлементных значений (таких как Natty). Для получения дополнительной информации о зависимой типизации в целом, учебник Agda - хорошее место для начала; Кроме того, Idris - это язык в разработке, который (примерно) разработан так, чтобы быть "Haskell с зависимыми типами".

Бенджамин Ходжсон
источник
@ Бенджамин К вашему сведению, ссылка на Идрис в конце, похоже, не работает.
Эрик Эйдт
@ErikEidt Ой, спасибо за указание на это! Я обновлю это.
Бенджамин Ходжсон
14

Это называется зависимой типизацией . Как только вы узнаете имя, вы сможете найти больше информации о нем, чем когда-либо надеялись. Есть также интересный язык, похожий на haskell, который называется Idris, который использует их изначально. Его автор сделал несколько действительно хороших презентаций на эту тему, которые вы можете найти на YouTube.

Карл Билефельдт
источник
Это не зависимая типизация вообще. Зависимая типизация говорит о типах во время выполнения, но измерение размерности в типе может быть легко выполнено во время компиляции.
DeadMG
4
@DeadMG Напротив, зависимая типизация говорит о значениях во время компиляции . Типы во время выполнения являются отражением, а не зависимой типизацией. Как вы можете видеть из моего ответа, запекание размерности в тип далеко не просто для общего измерения. (Вы могли бы определить newtype Vec2 a = V2 (a,a), newtype Vec3 a = V3 (a,a,a)и так далее, но это не то, о чем вопрос.)
Бенджамин Ходжсон
Ну, значения появляются только во время выполнения, так что вы не можете говорить о значениях во время компиляции, если не хотите решить проблему остановки. Все, что я хочу сказать, это то, что даже в C ++ вы можете просто использовать шаблон для размерности, и он работает отлично. Разве это не имеет эквивалента в Haskell?
DeadMG
4
@DeadMG «Типизированные» языки с полным спектром (например, Agda) фактически допускают произвольные вычисления на уровне терминов в языке типов. Как вы указываете, это подвергает вас риску попытаться решить проблему остановки. Афаик, большинство систем с зависимой типизацией решают эту проблему, не будучи завершенными по Тьюрингу . Я не C ++ парень, но меня не удивляет, что вы можете моделировать зависимые типы, используя шаблоны; шаблоны могут быть использованы во всех видах творчества.
Бенджамин Ходжсон
4
@BenjaminHodgson Вы не можете создавать зависимые типы с помощью шаблонов, потому что вы не можете имитировать тип pi. «Канонический» зависимый тип должен требовать бы вам нужно , это Pi (x : A). Bчто является функцией от Aдо , B xгде xаргумент функции. Здесь возвращаемый тип функции зависит от выражения, представленного в качестве аргумента. Однако все это можно стереть, это только время компиляции
Даниэль Гратцер