Có thể để kích thước nướng bánh quy thành một loại hình trong haskell?


20

Giả sử tôi muốn viết một thư viện liên quan đến vectơ và ma trận. Có thể nướng các kích thước thành các loại, để các hoạt động của kích thước không tương thích tạo ra lỗi tại thời gian biên dịch không?

Ví dụ, tôi muốn chữ ký của sản phẩm chấm giống như

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

trong đó dloại chứa một giá trị số nguyên duy nhất (đại diện cho kích thước của các vectơ này).

Tôi cho rằng điều này có thể được thực hiện bằng cách định nghĩa (bằng tay) một loại riêng cho mỗi số nguyên và nhóm chúng trong một lớp loại được gọi VecDim. Có một số cơ chế để "tạo ra" các loại như vậy?

Hoặc có lẽ một số cách tốt hơn / đơn giản hơn để đạt được điều tương tự?


3
Có, nếu tôi nhớ chính xác, có những thư viện để cung cấp mức độ gõ phụ thuộc cơ bản này trong Haskell. Tôi không đủ quen thuộc để cung cấp một câu trả lời tốt.
Telastyn 20/03/2015

Nhìn xung quanh, có vẻ như tensorthư viện đang đạt được điều này khá thanh lịch bằng cách sử dụng một datađịnh nghĩa đệ quy : noaxiom.org/tensor-documentation#ordutions
mitchus 20/03/2015

Đây là scala, không phải haskell, nhưng nó có một số khái niệm liên quan về việc sử dụng các loại phụ thuộc để ngăn chặn kích thước không khớp cũng như "loại" vectơ không khớp. chrisstucchio.com/blog/2014/ từ
Daenyth

Câu trả lời:


32

Để mở rộng câu trả lời của @ KarlBielefeldt, đây là một ví dụ đầy đủ về cách triển khai vectơ - liệt kê một số phần tử được biết đến tĩnh - trong Haskell. Giữ lấy chiếc mũ của bạn...

{-# 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

Như bạn có thể thấy trong danh sách dài các LANGUAGEchỉ thị, điều này sẽ chỉ hoạt động với một phiên bản GHC gần đây.

Chúng ta cần một cách biểu diễn độ dài trong hệ thống loại. Theo định nghĩa, một số tự nhiên là zero ( Z) hoặc nó là sự kế thừa của một số số tự nhiên khác ( S n). Vì vậy, ví dụ, số 3 sẽ được viết S (S (S Z)).

data Nat = Z | S Nat

Với phần mở rộng DataKinds , datakhai báo này giới thiệu một loại hàm tạo được gọi Natvà hai loại được gọi SZ- nói cách khác, chúng ta có các số tự nhiên cấp loại. Lưu ý rằng các loại SZkhông có bất kỳ giá trị thành viên nào - chỉ các loại loại *được cư trú bởi các giá trị.

Bây giờ chúng tôi giới thiệu một GADT đại diện cho các vectơ có độ dài đã biết. Lưu ý chữ ký loại: Vecyêu cầu một loạiNat (tức là một Zhoặc một Sloại) để thể hiện độ dài của nó.

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)

Định nghĩa của vectơ tương tự như các danh sách được liên kết, với một số thông tin cấp độ loại thêm về độ dài của nó. Một vectơ là một trong hai VNiltrường hợp nó có độ dài Z(ero) hoặc là một VConsô thêm một mục vào một vectơ khác, trong trường hợp đó độ dài của nó là nhiều hơn một vectơ khác ( S n). Lưu ý rằng không có đối số constructor của loại n. Nó chỉ được sử dụng tại thời gian biên dịch để theo dõi độ dài và sẽ bị xóa trước khi trình biên dịch tạo mã máy.

Chúng tôi đã định nghĩa một loại vectơ mang theo kiến ​​thức tĩnh về chiều dài của nó. Hãy truy vấn loại của một vài Vecgiây để cảm nhận về cách chúng hoạt động:

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

Sản phẩm chấm tiến hành giống như trong danh sách:

-- 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, mà 'zippily' áp dụng một vectơ các hàm cho một vectơ đối số, là Vecứng dụng <*>; Tôi đã không đặt nó trong một Applicativeví dụ vì nó trở nên lộn xộn . Cũng lưu ý rằng tôi đang sử dụng foldrtừ phiên bản do trình biên dịch tạo ra Foldable.

Hãy thử xem:

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

Tuyệt quá! Bạn gặp lỗi thời gian biên dịch khi bạn cố gắng dotvectơ có độ dài không khớp.


Đây là một nỗ lực tại một hàm để ghép các vectơ lại với nhau:

-- 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)

Độ dài của vectơ đầu ra sẽ là tổng độ dài của hai vectơ đầu vào. Chúng ta cần dạy trình kiểm tra kiểu cách thêm Nats với nhau. Đối với điều này, chúng tôi sử dụng một hàm cấp độ :

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

type familyTuyên bố này giới thiệu một hàm trên các loại được gọi :+:- nói cách khác, đó là một công thức cho trình kiểm tra loại để tính tổng của hai số tự nhiên. Nó được định nghĩa đệ quy - bất cứ khi nào toán hạng bên trái lớn hơn Zero, chúng ta sẽ thêm một vào đầu ra và giảm nó một lần trong lệnh gọi đệ quy. (Đây là một bài tập tốt để viết một hàm kiểu nhân hai Nats.) Bây giờ chúng ta có thể +++biên dịch:

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

Đây là cách bạn sử dụng nó:

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

Cho đến nay thật đơn giản. Thế còn khi chúng ta muốn làm ngược lại với ghép và chia một vectơ thành hai thì sao? Độ dài của vectơ đầu ra phụ thuộc vào giá trị thời gian chạy của các đối số. Chúng tôi muốn viết một cái gì đó như thế này:

-- 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)

nhưng tiếc là Haskell sẽ không cho chúng tôi làm điều đó. Việc cho phép giá trị của nđối số xuất hiện trong kiểu trả về (cái này thường được gọi là hàm phụ thuộc hoặc loại pi ) sẽ yêu cầu các loại phụ thuộc "toàn phổ", trong khi DataKindschỉ cung cấp cho chúng ta các hàm tạo kiểu được quảng bá. Nói cách khác, các hàm tạo kiểu SZkhông xuất hiện ở mức giá trị. Chúng tôi sẽ phải giải quyết các giá trị đơn lẻ cho một đại diện trong thời gian nhất định 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)

Đối với một loại nhất định n(có loại Nat), có chính xác một thuật ngữ của loại Natty n. Chúng ta có thể sử dụng giá trị singleton như một nhân chứng thời gian để n: học về một người Nattydạy chúng ta về nó nvà ngược lại.

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)

Chúng ta hãy quay nó:

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)

Trong ví dụ đầu tiên, chúng tôi chia thành công một vectơ ba phần tử ở vị trí 2; sau đó chúng tôi đã gặp một lỗi loại khi chúng tôi cố gắng phân tách một vectơ tại một vị trí quá cuối. Singletons là kỹ thuật tiêu chuẩn để tạo một loại phụ thuộc vào giá trị trong Haskell.

* singletonsThư viện chứa một số trình trợ giúp Mẫu Haskell để tạo các giá trị singleton như Nattycho bạn.


Ví dụ cuối cùng. Thế còn khi bạn không biết chiều của vectơ tĩnh? Ví dụ: nếu chúng ta đang cố gắng xây dựng một vectơ từ dữ liệu thời gian chạy dưới dạng danh sách thì sao? Bạn cần loại vectơ phụ thuộc vào độ dài của danh sách đầu vào. Nói cách khác, chúng ta không thể sử dụng foldr VCons VNilđể xây dựng một vectơ vì loại vectơ đầu ra thay đổi theo mỗi lần lặp của nếp gấp. Chúng ta cần giữ bí mật độ dài của vectơ từ trình biên dịch.

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

AVeclà một kiểu tồn tại : biến kiểu nkhông xuất hiện trong kiểu trả về của hàm tạo AVecdữ liệu. Chúng tôi đang sử dụng nó để mô phỏng một cặp phụ thuộc : fromListkhông thể cho bạn biết chiều dài của vectơ một cách tĩnh, nhưng nó có thể trả về một cái gì đó bạn có thể khớp mẫu để tìm hiểu độ dài của vectơ - Natty ntrong phần tử đầu tiên của bộ dữ liệu . Như Conor McBride đặt nó trong một câu trả lời có liên quan , "Bạn nhìn vào một thứ, và khi làm như vậy, hãy tìm hiểu về một thứ khác".

Đây là một kỹ thuật phổ biến cho các loại định lượng tồn tại. Bởi vì bạn thực sự không thể làm bất cứ điều gì với dữ liệu mà bạn không biết loại - hãy thử viết một hàm data Something = forall a. Sth a- tồn tại thường đi kèm với bằng chứng GADT cho phép bạn khôi phục loại ban đầu bằng cách thực hiện các kiểm tra khớp mẫu. Các mẫu phổ biến khác cho các tồn tại bao gồm đóng gói các hàm để xử lý loại ( data AWayToGetTo b = forall a. HeresHow a (a -> b)) của bạn , đó là một cách gọn gàng để thực hiện các mô-đun hạng nhất hoặc xây dựng trong một từ điển lớp loại ( data AnOrd = forall a. Ord a => AnOrd a) có thể giúp mô phỏng đa hình loại phụ.

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

Các cặp phụ thuộc rất hữu ích bất cứ khi nào các thuộc tính tĩnh của dữ liệu phụ thuộc vào thông tin động không có sẵn tại thời điểm biên dịch. Đây là filtervectơ:

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) 

Để dothai AVecs, chúng ta cần chứng minh với GHC rằng độ dài của chúng bằng nhau. Data.Type.Equalityđịnh nghĩa một GADT chỉ có thể được xây dựng khi các đối số kiểu của nó giống nhau:

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

Khi bạn khớp mẫu Refl, GHC biết điều đó a ~ b. Ngoài ra còn có một số chức năng giúp bạn làm việc với loại này: chúng tôi sẽ sử dụng gcastWithđể chuyển đổi giữa các loại tương đương và TestEqualityđể xác định xem hai Nattys có bằng nhau không.

Để kiểm tra sự bình đẳng giữa hai Nattys, chúng ta sẽ cần phải thực hiện sử dụng thực tế là nếu hai số đều bình đẳng, sau đó người kế vị của họ cũng đều bình đẳng ( :~:đồng dư trên S):

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

Khớp mẫu Reflở phía bên trái cho phép GHC biết điều đó n ~ m. Với kiến ​​thức đó, điều đó thật tầm thường S n ~ S m, vì vậy GHC cho phép chúng tôi trả lại một cái mới Reflngay lập tức.

Bây giờ chúng ta có thể viết một ví dụ của TestEqualityđệ quy đơn giản. Nếu cả hai số đều bằng 0, chúng bằng nhau. Nếu cả hai số đều có tiền thân, chúng bằng nhau nếu các số trước bằng nhau. (Nếu chúng không bằng nhau, chỉ cần quay lại 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

Bây giờ chúng ta có thể đặt các mảnh lại với nhau thành dotmột cặp có AVecđộ dài không xác định.

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)

Đầu tiên, khớp mẫu trên hàm AVectạo để rút ra biểu diễn thời gian chạy của độ dài của vectơ. Bây giờ sử dụng testEqualityđể xác định xem những độ dài đó có bằng nhau không. Nếu có, chúng ta sẽ có Just Refl; gcastWithsẽ sử dụng bằng chứng bình đẳng đó để đảm bảo rằng nó dot u vđược đánh máy tốt bằng cách loại bỏ n ~ mgiả định ngầm định của nó .

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

Lưu ý rằng, vì một vectơ không có kiến ​​thức tĩnh về độ dài của nó về cơ bản là một danh sách, chúng tôi đã triển khai lại một cách hiệu quả phiên bản danh sách dot :: Num a => [a] -> [a] -> Maybe a. Sự khác biệt là phiên bản này được triển khai theo các vectơ ' dot. Đây là điểm: trước khi trình kiểm tra loại sẽ cho phép bạn gọi dot, bạn phải kiểm tra xem danh sách đầu vào có cùng độ dài hay không testEquality. Tôi có xu hướng đi ifsai đường vòng, nhưng không phải trong một thiết lập phụ thuộc!

Bạn không thể tránh sử dụng các trình bao bọc hiện sinh ở các cạnh của hệ thống, khi bạn xử lý dữ liệu thời gian chạy, nhưng bạn có thể sử dụng các loại phụ thuộc ở mọi nơi trong hệ thống của mình và giữ các trình bao bọc tồn tại ở các cạnh, khi bạn thực hiện xác thực nhập.

Nothingkhông có nhiều thông tin, bạn có thể tinh chỉnh thêm loại dot'để trả về một bằng chứng cho thấy độ dài không bằng nhau (dưới dạng bằng chứng cho thấy sự khác biệt của chúng không phải là 0) trong trường hợp thất bại. Điều này khá giống với kỹ thuật Haskell tiêu chuẩn sử dụng Either String ađể có thể trả về thông báo lỗi, mặc dù thuật ngữ bằng chứng hữu ích hơn nhiều về mặt tính toán so với chuỗi!


Do đó, kết thúc chuyến tham quan này về một số kỹ thuật phổ biến trong lập trình Haskell được gõ phụ thuộc. Lập trình với các loại như thế này trong Haskell thực sự rất tuyệt, nhưng thực sự rất khó xử cùng một lúc. Việc chia tất cả dữ liệu phụ thuộc của bạn thành nhiều biểu diễn có nghĩa tương tự - Natloại, Natloại, Natty nđơn - thực sự khá cồng kềnh, mặc dù có sự tồn tại của trình tạo mã để trợ giúp cho bản tóm tắt. Hiện tại cũng có những hạn chế về những gì có thể được thăng cấp lên cấp độ loại. Mặc dù đó là trêu ngươi! Tâm trí chú ý đến các khả năng - trong tài liệu có các ví dụ trong Haskell về kiểu gõ mạnh printf, giao diện cơ sở dữ liệu, công cụ bố trí UI ...

Nếu bạn muốn đọc thêm, sẽ có một tài liệu ngày càng tăng về việc gõ Haskell một cách phụ thuộc, cả được xuất bản và trên các trang web như Stack Overflow. Một tốt điểm bắt đầu là các Hasochism giấy - giấy đi qua ví dụ này rất (trong số những người khác), thảo luận về những phần đau đớn trong một số chi tiết. Bài báo Singletons thể hiện kỹ thuật của các giá trị đơn lẻ (chẳng hạn như Natty). Để biết thêm thông tin về cách gõ phụ thuộc nói chung, hướng dẫn Agda là một nơi tốt để bắt đầu; Ngoài ra, Idris là một ngôn ngữ đang được phát triển (đại khái) được thiết kế thành "Haskell với các loại phụ thuộc".


@Benjamin FYI, liên kết Idris ở cuối dường như bị phá vỡ.
Erik Eidt

@ErikEidt oops, cảm ơn bạn đã chỉ ra điều đó! Tôi sẽ cập nhật nó.
Benjamin Hodgson

14

Đó gọi là gõ phụ thuộc . Một khi bạn biết tên, bạn có thể tìm thấy nhiều thông tin về nó hơn bao giờ bạn có thể muốn. Ngoài ra còn có một ngôn ngữ giống như ngôn ngữ thú vị được gọi là Idris sử dụng chúng nguyên bản. Tác giả của nó đã thực hiện một vài bài thuyết trình thực sự hay về chủ đề mà bạn có thể tìm thấy trên youtube.


Điều đó hoàn toàn không phụ thuộc vào việc gõ. Gõ phụ thuộc nói về các kiểu trong thời gian chạy, nhưng nướng chiều vào loại có thể dễ dàng được thực hiện tại thời gian biên dịch.
DeadMG

4
@DeadMG Ngược lại, gõ phụ thuộc nói về các giá trị tại thời gian biên dịch . Các loại tại thời gian chạy là phản ánh, không phụ thuộc gõ. Như bạn có thể thấy từ câu trả lời của tôi, nướng chiều vào loại không dễ dàng cho kích thước chung. (Bạn có thể định nghĩa newtype Vec2 a = V2 (a,a), newtype Vec3 a = V3 (a,a,a)v.v. nhưng đó không phải là những gì câu hỏi đang hỏi.)
Benjamin Hodgson

Chà, các giá trị chỉ xuất hiện trong thời gian chạy, vì vậy bạn không thể thực sự nói về các giá trị tại thời gian biên dịch trừ khi bạn muốn giải quyết vấn đề Dừng. Tất cả những gì tôi nói là ngay cả trong C ++, bạn chỉ có thể tạo mẫu theo chiều và nó hoạt động tốt. Điều đó không có tương đương trong Haskell?
DeadMG

4
@DeadMG "Toàn phổ" các ngôn ngữ được gõ phụ thuộc (như Agda) trên thực tế cho phép tính toán mức độ tùy ý trong ngôn ngữ loại. Như bạn chỉ ra, điều này khiến bạn có nguy cơ cố gắng giải quyết vấn đề Ngừng. Hầu hết các hệ thống gõ phụ thuộc, afaik, punt về vấn đề này bằng cách không hoàn thành Turing . Tôi không phải là người của C ++ nhưng điều đó không làm tôi ngạc nhiên khi bạn có thể mô phỏng các loại phụ thuộc bằng cách sử dụng các mẫu; mẫu có thể bị lạm dụng trong tất cả các loại cách sáng tạo.
Benjamin Hodgson

4
@BenjaminHodgson Bạn không thể thực hiện các loại phụ thuộc với các mẫu vì bạn không thể mô phỏng loại pi. Loại phụ thuộc "chính tắc" sẽ yêu cầu bạn cần Pi (x : A). Blà một hàm từ Ađến B xđâu xlà đối số của hàm. Ở đây kiểu trả về của hàm phụ thuộc vào biểu thức được cung cấp dưới dạng đối số. Tuy nhiên, tất cả những thứ này có thể bị xóa, đó chỉ là thời gian biên dịch
Daniel Gratzer
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.