I have just learnt about the DataKinds
extension, type-level literals, and found out that type-levels natural numbers can be compared using constraints provided in the Data.Type.Ord
such as (>)
, and manipulated using type-level operations provided in GHC.TypeNats
such as (+)
.
With these in mind, I came across the idea of implementing a safe version of !!
for length-indexed vectors. The idea is simple: you pass in an ix :: Natural
as the index, and a vec :: Vec len a
with length len
and type a
, and you get the element at index ix
. The compiler should detect and shout at you for any illegal ix
(ix < 0 || ix >= len
) or vec
(whose len == 0
).
So I began by defining the type signature for such safeAt
(imports and magic comments of extensions ignored):
infixr 5 :.
data Vec :: Nat -> * -> * where
Nil :: Vec 0 a
(:.) :: a -> Vec m a -> Vec (m + 1) a
safeAt :: forall (ix :: Nat) (len :: Nat) (proxy :: Nat -> *) a.
(KnownNat ix, {- ix >= 0, -} len >= 1, ix < len) =>
proxy ix -> Vec len a -> a
safeAt = undefined
Working as intended:
v :: Vec 10 Int
v = 1 :. 2 :. 3 :. 4 :. 5 :. 6 :. 7 :. 8 :. 9 :. 10 :. Nil
ok :: Int
ok = safeAt (Proxy :: Proxy 3) v
oops :: Int
oops = safeAt (Proxy :: Proxy 10) v -- expected compilation error
Here I made use of a proxy to hold the ix
at the type-level so it can be checked by the type-level constraints. This however became a problem when I try to implement safeAt
:
safeAt p (x:.xs) = case natVal p of
0 -> x
n -> safeAt (??? (n - 1)) xs
I need to promote (n - 1)
to Proxy :: Proxy (n - 1)
. I cannot work out such function, not even writing out its type signature, and came to doubt if such a type-dependent function would even exist in the Haskell's typing system. I know you can pull down a Nat from type-level to term-level using natVal
, but is there a "reverse" of this? Maybe there's a magic function out there that I don't know? Also I believe there are other problems in this implementation, such as how would you comfort the compiler that (ix - 1)
is KnownNat
and (len - 1) >= 1
still given the proxy p
is not holding a 0
. Maybe there are some alternative approaches out there to implement safeAt
in a non-recursive way? I have so much confusions.
Proxies are a mostly obsolete/legacy thing since -XTypeApplications
and -XAllowAmbiguousTypes
have been available. Using these extensions together with -XScopedTypeVariables
gives you almost everything proxies can do.
In many cases, including yours, that's not enough though: you need something stronger than proxies to propagate the KnownNat
constraint. Singletons offer that capability. A Sing n
for n :: Nat
packs the KnownNat
constraint in a value like a proxy, in a way that you can carry out computations with it.
Throwing in the big singletons-base
package (kinda overkill), you can start with the following:
{-# LANGUAGE TypeApplications #-}
import Prelude.Singletons (SNum(..), sing)
import Data.Kind (Type)
safeAt :: forall (ix :: Nat) (len :: Nat) a.
Sing ix -> Vec len a -> a
safeAt i (x:.xs) = case fromSing i of
0 -> x
_ -> safeAt (i %- sing @1) xs
Of course that's not all: how do we express those size constraints?
It seems obvious that ix < len
implies ix-1 < len-1
, but this is the kind of trivial thing compilers tend to disagree with.
Welcome to the joy of dependently-typed programming!
...There's definitely work on getting this kind of thing behaving smoothly, both in GHC itself and the singletons-presburger plugin but I'm not sure how well it works.
Pragmatically speaking, you may be better off with a different approach: either
finite-typelits
offers that.In the first version, you don't need any proxies or singletons. This will do:
{-# LANGUAGE ScopedTypeVariables, TypeApplications, AllowAmbiguousTypes #-}
unsafeAt :: Integer -> Vec len a -> a
unsafeAt = ...
safeAt :: ∀ ix len a . (KnownNat len, KnownNat ix, ix >= 0, ix < len)
=> Vec len a -> a
safeAt = unsafeAt (natVal @ix Proxy)