-
Notifications
You must be signed in to change notification settings - Fork 37
Description
For the longest time, something about the way record selectors are singled has felt slightly off to me. I think the best way to illustrate the unease I feel is to point to this example:
newtype Unit = MkUnit { runUnit :: () }
-- runUnit :: Unit -> ()
f :: Unit -> ()
f = runUnit
Surprisingly, this code successfully promotes, but doesn't single: sF
will fail to typecheck. What is going on here? The heart of the matter is that this is the definition of sRunUnit
:
data instance Sing :: Unit -> Type where
SMkUnit :: { sRunUnit :: Sing u } -> Sing (MkUnit u)
In other words,
sRunUnit :: Sing (MkUnit u) -> Sing u
For the rest of this issue, I will refer to this style of singling record selectors as the "traditional" approach. This approach is actually quite distinct from how most definitions are singled. For instance, compare sRunUnit
to an alternative way to define a "record selector" for MkUnit
(I put "record selector" in quotes because it's not really one, but it's as close as I can get without using record syntax):
runUnit2 :: Unit -> ()
runUnit2 (MkUnit unit) = unit
When singled, this becomes:
sRunUnit2 :: Sing (unit :: Unit) -> Sing (RunUnit2 unit)
For the rest of this issue, I will refer to this as the "manual" approach to singling record selectors. Unlike the traditional approach, the manual approach does not constrain the type of its argument (beyond saying that it should be of kind Unit
), and it incorporates a type family into its return type.
What's even more interesting is that using the traditional runUnit
causes f = runUnit
to fail to single, but swapping runUnit
out the its manual equivalent, runUnit2
, makes f
single successfully. That's because in the traditional definition of f
:
sF :: Sing (unit :: Unit) -> Sing (F unit)
sF = sRunUnit
There is no constraint in scope that unit ~ MkUnit u
(for some u
), so sRunUnit
fails to typecheck. On the other hand, sRunUnit2
requires no such constraint, so it happily typechecks.
To make things even stranger, the traditional approach to singling records is somewhat at odds with the way records are promoted. Here is the promoted version of runUnit
:
-- type RunUnit :: Unit -> ()
type family RunUnit (unit :: Unit) :: () where
RunUnit (MkUnit unit) = unit
In spirit, this is actually closer to the manual approach than the traditional one!
That's not to say that the traditional approach is totally useless. While I've demonstrated some scenarios where the manual approach wins out, there are other situations where the traditional approach is better:
-
Only the traditional definition can be used with record syntax (record construction, record updates, pattern matching, etc.).
-
In some scenarios, traditional singled records can avoid partiality. If you were to single the following data type:
data Vec :: Type -> Nat -> Type where VNil :: Vec a Z (:#) :: { vhead :: a, vtail :: Vec a n } -> Vec a (S n)
Then if
sVhead
andsVtail
were defined traditionally, they would be total functions, as their type signatures would require that the first argument have typeSing (x :# xs)
(i.e., passingSVNil
would be a type error). If they were defined manually, however, they would be partial.
This seems to indicate to me that perhaps singletons
should be offering both forms of records. If nothing else, singletons
' own code would benefit from having the manual versions available, as there are many times where we have to jump through hoops to single code which uses newtype record selectors. If we did this, though, we'd have to answer the following questions:
- What should the naming conventions for traditional and manual singled record selectors be?
- If the naming convention for manual records ends up being different from what
singletons
currently offers, should we change the naming conventions for promoted records to match that of manual singled records? (If we don't, we'd have a discrepancy between the two, despite the fact that manual singled records would use these promoted records in their return types.)
Another option is to just observe this weirdness in the README
, and require that anyone who wants to write functions like f
above will have to define manual record selectors, well, manually. Currently, the README
is rather silent about this point—the only oddity about records that it makes note of concerns record updates, which is a rather different problem.