While the addition of coercible is wonderful, some quirks of it are I argue dangerously misleading.
The documentation says
The function
coerce
allows you to safely convert between values of types that have the same representation with no run-time overhead. In the simplest case you can use it instead of a newtype constructor, to go from the newtype’s concrete type to the abstract type. But it also works in more complicated settings, e.g. converting a list of newtypes to a list of concrete types.
Coercible
is a two-parameter class that has instances for typesa
andb
if the compiler can infer that they have the same representation. This class does not have regular instances; instead they are created on-the-fly during type-checking. Trying to manually declare an instance ofCoercible
is an error.
And at the very bottom
For more details about this feature, please refer to Safe Coercions by Joachim Breitner, Richard A. Eisenberg, Simon Peyton Jones and Stephanie Weirich.
One might intuitively expect this to be a way for more safely doing what otherwise one might use unsafeCoerce
for. And I do believe even the paper hints at this.
However, the details of the rules make this naive assumption unsound.
Suppose an author had previously been doing the following.
data Data a b = MkData (Maybe a) !b
deriving Show
castList :: [Data a Void] -> [Void]
castList = unsafeCoerce
castMaybeList :: [Maybe (Data a Void)] -> [Maybe Void]
castMaybeList = unsafeCoerce
revCastList :: [Void] -> [Data a Void]
revCastList = unsafeCoerce
revCastMaybeList :: [Maybe Void] -> [Maybe (Data a Void)]
revCastMaybeList = unsafeCoerce
The author now reads about this new ‘Coercible’ mechanism, and thinks that it can dramatically improve their situation.
So they try to encapsolate all they were previously doing by hand with this:
unsafeCoercion :: forall a b. Coercion a b
unsafeCoercion = unsafeCoerce (Coercion :: Coercion a a)
voidCoercion :: Coercion (Data a Void) Void
voidCoercion = unsafeCoercion
Unfortunately and surprisingly, they have now made their code unsound! Coercion a b
does not merely assert that the two types have the same representation. That holds true here, the only sound terms of either type are bottoms. It asserts more. Buried in the rules of the paper, for datatypes one can transform the coercion into a coercion of its type variables.
As a result, the following code goes through:
bogusCoerce :: forall a b. a -> b
bogusCoerce = (case c of Coercion -> coerce) where
c :: Coercion a b
c = extract mid
extract :: forall a' b' c'. Coercion (Data a' c') (Data b' c') -> Coercion a' b'
extract Coercion = Coercion
mid :: Coercion (Data a Void) (Data b Void)
mid = combine voidCoercion voidCoercion
combine :: forall a' b' c'. Coercion a' c' -> Coercion b' c' -> Coercion a' b'
combine Coercion Coercion = Coercion
While the poor author might have thought their use of unsafeCoerce
was nicely encapsulated by their voidCoercion
, they would be quite incorrect due to the fact that extract
is a legal function!
Worse, the fact that Coercible is a typeclass encourages one to have things automatically solved. One might try to be clever, and create the following:
class (forall a. Coercible (Data a Void) Void) => CoercionContext where
class (forall (a :: Type). Coercible a a) => CoercionContextDummy where
instance CoercionContextDummy
data Dict (c :: Constraint) = c => Dict
coercionContext :: Dict CoercionContext
coercionContext = unsafeCoerce dummy where
dummy :: Dict CoercionContextDummy
dummy = Dict
Now, currently GHC does not get very clever about using Coercible constraints in the context, especially quantified ones. However, this is hardly a feature, and actually quite annoying in practice. It would hardly be surprising nor should it be undesirable for GHC to get cleverer about this!
But its limited cleverness is the only reason the following does not compile yet!
bogusCoerce :: a -> b
bogusCoerce = (case coercionContext of Dict -> coerce)
Now, obviously one would notice something as absurd as that.
But suppose one did something more like
newtype Age = MkAge Int
newtype Bar a b = MkBar (Foo b a)
newtype ZipList a = MkZipList [a]
foo :: [Foo Int (Data a Void)] -> ZipList (Bar Void Age)
foo = (case coercionContext of Dict -> coerce)
Perfectly reasonable code. Throw in a traverse with that and it wouldn’t be surprising at all.
Maybe it doesn’t compile today due to lack of cleverness in solving Coercible instances, but maybe someone put in the elbow grease to fancy it up.
And then one day in some other portion of code, Bar
is changed for some reason.
Not to worry though, this code should stop compiling then! Except… not if the compiler is clever enough to instead go through the argument used in the first bogusCoerce
In conclusion: Accidental unsoundness is bad, and the current design makes it an easy trap to fall into. Worse, it isn’t that hard to move the unsoundness up into the implicit code generated by constraint solving, and so as constraint solving gets cleverer the risk of the compiler exploiting that unsoundness without any visible code showing it doing so increases.