-
Notifications
You must be signed in to change notification settings - Fork 37
Description
Currently, singletons-th adopts a hard-line stance regarding quantifiers in type signatures. Namely, they must all appear at the top level in this order:
forall tvb_1 ... tvb_m. ctx => arg_1 -> ... arg_n -> resWith the additional stipulations that:
- Only invisible
foralls are permitted. A type likeforall a -> a(with a visibleforall) is not allowed. - Nested
foralls and contexts are not permitted inctx, theargs, theres, or the kinds of thetvbs.
In singletons parlance, this sort of type is referred to as a vanilla type. Vanilla types are a subset of rank-1 types, where quantifiers are allowed to appear contravariantly at a depth of at most 1.¹ singletons-th does not support all rank-1 types, as it will currently reject this one:
const2 :: forall a. a -> forall b. b -> aIn #401, I originally came to the conclusion that we should not support anything more sophisticated than vanilla types. Recently, however, I'm reconsidering that stance. One thing that is making me reconsider my stance is the advent of visible foralls in types, where you can write things like this:
-- Definition:
idv :: forall a -> a -> a
idv (type a) x = x :: a
-- Usage:
n :: Bool
n = idv (type Bool) TrueThis is an exciting step towards full dependent types. Moreover, it would be interesting to support this feature in singletons. On the surface, it appears simple to support. Here is roughly how the example above would look when promoted:
-- Definition:
type Idv :: forall a -> a -> a
type family Idv a x where
Idv a x = x :: a
-- Usage:
type N :: Bool
type family N where
N = Idv Bool TrueAnd singled:
-- Definition:
sIdv :: forall a -> forall (x :: a). Sing x -> Sing (Idv a x)
sIdv (type a) (sX :: Sing x) = sX :: Sing (x :: a)
-- Usage:
sN :: Sing N
sN = sIdv (type Bool) STrueNote that when promoted (type a) simply becomes a, and (type a) isn't changed at all when singled. The most interesting difference when singled is the need to have a separate, invisible forall (x :: a). after the visible forall a ->. (More on this in a bit.)
As cool as this idea is, it is not possible within singletons-th's current restriction that all types must be vanilla. What if we relaxed this restriction to allow all rank-1 types, however? Although I decided against this in #401, all of the problematic examples that I came up with are actually rank-2 types, not rank-1. As it turns out, each of the issues that I identified in #401 are manageable when the scope is limited to rank-1 types:
Defunctionalization symbols
You definitely can't make defunctionalization symbols for rank-2 or higher of types, as you would need to define Apply instances with polytypes as arguments. This just isn't possible in GHC, even with the advent of Quick Look impredicativity.
With rank-1 types, we can avoid this problem by applying the workaround described in Wrinkle 2: Non-vanilla kinds in Note [Defunctionalization game plan]. The trick is that we can still generate defunctionalization symbols for things with non-vanilla types, but we may have to make some sacrifices:
- For types with nested
foralls, the defunctionalization symbols would put all of theforalls up front to avoid the impredicativity issues described above. - For types with visible
foralls, the defunctionalization symbols would turn something likeforall a -> aintoforall a. Type ~> a.
For many use cases, these sacrifices are acceptable, as you can still profitably use the defunctionalization symbols even with the slightly different treatment of foralls.
In singled definitions
Singling rank-2 or higher definitions would be tricky, as the examples in #401 demonstrate. Singling rank-1 definitions, on the other hand, would be much more tractable. In today's singletons-th, the algorithm works roughly like so: given a (vanilla) type signature of the form:
f :: forall tvb_1 ... tvb_m. arg_1 -> ... arg_n -> resThe singled type signature would become (roughly):
sF :: forall tvb_1 ... tvb_m
(s_1 :: arg_1) ... (s_n :: arg_n).
Sing s_1 -> ... -> Sing s_n -> Sing (F s_1 ... s_n :: res)Where F and sF are the promoted and singled versions of f, respectively. Note that we introduce additional s type variables to instantiate Sing with. For lack of a better name, I'll call these "Sing type variables".
To handle all rank-1 types, this algorithm would need to be generalized slightly to be able to handle nested foralls and visible foralls. Here are two possible algorithms for making this work. (Feel free to skip reading the algorithm descriptions if you don't care about the ugly details.)
Algorithm 1: The nearest telescope algorithm
- Quantify each
Singtype variable in an invisibleforalltelescope directly after the nearestforalltelescope (starting from the::) in which all of the variables in theSingtype variable's kind have been bound by preceding telescopes. - If a
Singtype variable telescope appears directly after another invisibleforalltelescope, combine the two telescopes into one.
Here are some examples of how this would work:
A vanilla type signature
const :: forall a b. a -> b -> aWe need to quantify two Sing type variables, one for each of the arguments preceding a function arrow. The kinds of these Sing type variables are a and b, and the nearest forall telescope that we could pick after which a and b would be bound is forall a b.. As a result, we will quantify the Sing type variables directly after this telescope:
sConst :: forall a b.
forall (s1 :: a) (s2 :: b).
Sing s1 -> Sing s2 -> Sing (Const2 s1 s2 :: a)Then, we would combine the Sing type variable telescope into the preceding one, which also uses invisible foralls:
sConst :: forall a b (s1 :: a) (s2 :: b).
Sing s1 -> Sing s2 -> Sing (Const2 s1 s2 :: a)This is exactly the same type signature that sConst would receive in today's singletons-th.
An example with nested, invisible foralls
slurmp :: forall a.
a ->
forall b.
b -> a -> (a, b) -> aThis time, we need to quantify four Sing type variables. The first and third Sing type variables have kind a, and the nearest forall telescope that we could pick after which a would be bound is forall a.. The kind of the second Sing type variable is b, and the kind of the fourth Sing type variable is (a, b). Because both of these kinds mention b, the nearest forall telescope that we could pick after which b would be bound is forall b. These constraints give rise to the following type signature:
sSlurmp :: forall a.
forall (s1 :: a) (s3 :: a).
Sing s1 ->
forall b.
forall (s2 :: b) (s4 :: (a, b)).
Sing s2 -> Sing s3 -> Sing s4 -> Sing (Slurmp s1 s2 s3 s4 :: a)Finally, we combine adjacent invisible forall telescopes to get:
sSlurmp :: forall a (s1 :: a) (s3 :: a).
Sing s1 ->
forall b (s2 :: b) (s4 :: (a, b)).
Sing s2 -> Sing s3 -> Sing s4 -> Sing (Slurmp s1 s2 s3 s4 :: a)An example with a visible forall
slurmpVis :: forall a.
a ->
forall b ->
b -> a -> (a, b) -> aThe type of slurmpVis is identical to slurmp except that the b is quantified visibly. As a result, the forall b -> and forall (s2 :: b) (s4 :: (a, b)). telescopes would not be combined, resulting in a final singled type signature of:
sSlurmpVis :: forall a (s1 :: a) (s3 :: a).
Sing s1 ->
forall b ->
forall (s2 :: b) (s4 :: (a, b)).
Sing s2 -> Sing s3 -> Sing s4 -> Sing (SlurmpVis s1 b s2 s3 s4 :: a)Algorithm 2: The nearest function argument algorithm
Algorithm 1 (the nearest telescope algorithm) is designed to maintain backwards compatibility with singletons-th's existing treatment of foralls in singled type signatures. However, it is not obvious that Algorithm 1 produces the nicest type signatures for things with nested foralls. For instance, in the type of sSlurmp above, we quantified s3 before s2, which feels somewhat awkward.
Algorithm 2 (The nearest function argument algorithm) is an attempt to address this awkwardness. It only has one rule:
- Quantify each
Singtype variables_idirectly before the function argumentSing s_i.
That's it. There's no mention of telescopes in this algorithm, which makes it conceptually simpler. On the other hand, it does not preserve the existing behavior of how singletons-th quantifies type variables.
Here are the same examples from above, but using Algorithm 2:
A vanilla type signature
const :: forall a b. a -> b -> awould be singled to:
sConst :: forall a b.
forall (s1 :: a). Sing s1 ->
forall (s2 :: b). Sing s2 ->
Sing (Const s1 s2 :: a)An example with nested, invisible foralls
slurmp :: forall a.
a ->
forall b.
b -> a -> (a, b) -> awould be singled to:
sSlurmp :: forall a.
forall (s1 :: a). Sing s1 ->
forall b.
forall (s2 :: b). Sing s2 ->
forall (s3 :: a). Sing s3 ->
forall (s4 :: (a, b)). Sing s4 ->
Sing (Slurmp s1 s2 s3 s4 :: a)An example with a visible forall
slurmpVis :: forall a.
a ->
forall b ->
b -> a -> (a, b) -> awould be singled to:
sSlurmpVis :: forall a.
forall (s1 :: a). Sing s1 ->
forall b ->
forall (s2 :: b). Sing s2 ->
forall (s3 :: a). Sing s3 ->
forall (s4 :: (a, b)). Sing s4 ->
Sing (SlurmpVis s1 b s2 s3 s4 :: a)Either algorithm should suffice, but we will have to think carefully about the tradeoffs involved.
tl;dr It is quite possible to promote and single all rank-1 type signatures, even those with visible foralls. The downside is that we may have to sacrifice the fidelity of the defunctionalization symbols involved a little. I think this is an acceptable price to pay, but others may feel differently. What are your thoughts?
¹For a more formal definition of what a rank-1 type is, see Section 3.1 of Practical type inference for arbitrary-rank types.