New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement `Coercible` for safe zero-cost coercions #3351

Open
wants to merge 4 commits into
base: master
from

Conversation

Projects
None yet
6 participants
@lunaris
Copy link

lunaris commented May 4, 2018

This PR adds Coercible for safe zero-cost coercions, as per the similar feature in GHC Haskell. It's intended to be the first step towards implementing deriving via (#3302). Compiler support only boils down to the constraint Coercible -- the actual function coerce :: forall a b. Coercible a b => a -> b can be implemented in library code (as unsafeCoerce, thus receiving the same inlining/elimination treatment and so being zero-cost). The tests will fail because of this point because I need to find a place to put Safe.Coerce (the module which exposes coerce). Happy to take input on how to best accomplish this.

Changes

  • Define a new built-in module, Prim.Coerce which exports a single
    type class, Coercible a b, which relates types with identical runtime
    representations.

  • Extend the type checker's class solver to solve constraints of the
    form Coercible a b automatically, as per the rules in the paper "Safe
    Zero-Cost Coercions for Haskell" (though simpler due to the absence of
    some of Haskell's features like type families).

  • As part of the above point, add support for role inference to the type
    checker.

-- (e.g. in the case `data F a b = F (a (F a b))`, the use of `a` as
-- a function/constructor would trigger this case)
_ ->
walk t1

This comment has been minimized.

@kcsongor

kcsongor May 4, 2018

Contributor

It looks like this case should mark all t2s as representational:

data F a b = F (a b)

As a is polymorphic, we don't know what its role annotation is going to be, therefore we should be careful and assume that its argument is representational (because it very well could be!).

That means that for F, b would be inferred representational as well.
I think the current implementation will infer phantom for b, allowing to coerce
F Maybe Int and F Maybe String

This comment has been minimized.

@kcsongor

kcsongor May 4, 2018

Contributor

It looks like this case should mark all t2s as representational:

Or rather, just walk all t2s, as that will mark all type variables representational anyway, and ensure that we don't accidentally mark phantom things representational (which would be safe, but undesirable).

So:

data G a b = G (a (Phantom b))

data Phantom a = Phantom

will not get tricked into marking b as representational.


Note: this case is actually interesting, because it differs from what GHC does. Not having to consider nominal roles results in a non-trivial difference:

For G above, GHC would infer

type role G representational nominal

This is because with nominal as a possibility, we must assume that a's argument can be nominal, therefore Phantom b is in a nominal role. However, nominal is "infectious": if Phantom b is nominal, then all type variable therein must be nominal too (b here). This being the case, GHC rightly marks b as nominal, and stops. In the paper, this is accounted for by the rules that mark all free variables nominal.

However, with only phantom and representational roles, we can do better than simply downgrading nominal to representational! Phantom b being in a representational role doesn't "infect" b: walking is sufficient to determine the above result.

As a result, GHC would never allow to coerce between G Maybe Int and G Maybe String - but we can!

-- argument is representational (since its use of our parameters is
-- important) and terminate if the argument is phantom.
TypeConstructor t1Name ->
let t1Roles = inferRoles env t1Name

This comment has been minimized.

@kcsongor

kcsongor May 4, 2018

Contributor

What happens with mutually recursive types?

data F a = F (G a)
data G a = G (F a)
-- appears (with a default role of phantom) and that they appear in the
-- right order.
let ctorRoles = foldMap (foldMap walk . snd) ctors
in map (\(tv, _) -> (tv, fromMaybe Phantom (lookup tv ctorRoles))) tvs

This comment has been minimized.

@kcsongor

kcsongor May 4, 2018

Contributor

This use of lookup seems like we're taking the role of the first occurrence of a in the constructors.

Does it mean that for

data Phantom a = Phantom

data F a = MkF (Phantom a) a

we get

type role F phantom

?

This comment has been minimized.

@lunaris

lunaris May 7, 2018

I thought you were right but after some testing I think I actually get away with this due to the fact that Phantom parameters are not realised at all. E.g. for the types:

data Phantom a = Phantom
data F a = MkF (Phantom a) a

walking the MkF constructor for F yields:

[ [], [("a", Representational)] ]

That is, the Phantom use results in [], not ("a", Phantom). So this works. I think this is OK but am open to alternative implementations.

This comment has been minimized.

@kcsongor

kcsongor May 7, 2018

Contributor

Ah, you're right - I missed this. Perhaps then walk should just return a list of representational types? (as it already does, but its type is somewhat misleading)

@rightfold

This comment has been minimized.

Copy link
Contributor

rightfold commented May 7, 2018

Without explicit role annotations, how can the programmer ensure safety when using phantom types that should prevent coercibility? There are many thinkable instances of this with the FFI.

@kcsongor

This comment has been minimized.

Copy link
Contributor

kcsongor commented May 7, 2018

@rightfold you certainly raise a good point, I would imagine that by default all arguments to foreign types have to be representational

@lunaris

This comment has been minimized.

Copy link

lunaris commented May 7, 2018

I've implemented @kcsongor's suggestion to infer Representational for all foreign declarations though will look into how cheaply I can implement role signatures (let the bikeshedding begin).

@paf31

This comment has been minimized.

Copy link
Member

paf31 commented May 8, 2018

I like the idea of using Representational as the default. Generally, I like the idea of keeping roles hidden from the user entirely.

@lunaris

This comment has been minimized.

Copy link

lunaris commented May 8, 2018

As it currently stands that's what this branch does now. I've prototype role declarations in another branch but that's a separate discussion I think.

lunaris added some commits Apr 26, 2018

Implement `Coercible` for safe zero-cost coercions
* Define a new built-in module, `Prim.Coerce` which exports a single
type class, `Coercible a b`, which relates types with identical runtime
representations.

* Extend the type checker's class solver to solve constraints of the
form `Coercible a b` automatically, as per the rules in the paper "Safe
Zero-Cost Coercions for Haskell" (though simpler due to the absence of
some of Haskell's features like type families).

* As part of the above point, add support for role inference to the type
checker.

@lunaris lunaris force-pushed the lunaris:feature/coercible branch from 6001065 to 9e243ab Jun 19, 2018

@lunaris

This comment has been minimized.

Copy link

lunaris commented Jun 19, 2018

I've rebased this on master; can we make any progress on merging it? As noted, we'd need to decide:

  • Module and package to expose coerce -- currently I've hacked a Safe.Coerce module into purescript-unsafe-coerce in the tests/support/bower_components directory to make the tests/purs/passing/Coercible.purs test pass. Implementation is just:
module Safe.Coerce (coerce) where

import Prim.Coerce (class Coercible)
import Unsafe.Coerce (unsafeCoerce)

coerce :: forall a b. Coercible a b => a -> b
coerce = unsafeCoerce
  • If we are happy with the situation regarding roles and their inference etc., which it sounds like we might be. Not sure though.
@vladciobanu

This comment has been minimized.

Copy link
Contributor

vladciobanu commented Jun 19, 2018

Just a minor note, I think most of Environment.hs was recently (0.12) updated to have more descriptive names for type parameters. I think it might help newcomers if a and b were from and to, or something similar.

Other than that, this looks good to me and I hope it gets merged soon.

@LiamGoodacre
Copy link
Member

LiamGoodacre left a comment

I'll do a proper review tonight.

, "First, as a trivial base-case, reflexivity - any type has the same"
, "representation as itself:"
, ""
, " instance Coercible a a"

This comment has been minimized.

@LiamGoodacre

LiamGoodacre Jun 19, 2018

Member

The example code here and later on aren't in PureScript syntax (no instance naming). What do you think about:

instance reflexivity :: Coercible a a
-- recursive call's result. This is true, but fine, since this tuple will
-- never be looked up when building the final result, which only looks at
-- the variables defined as parameters to the type.
walk t

This comment has been minimized.

@LiamGoodacre

LiamGoodacre Jun 19, 2018

Member

In data T a = T (forall a. a -> a) will this produce Representational a, when it should be Phantom?

-- reduce the first or second argument -- if the constraint is
-- solvable, either path will yield the same outcome. Consequently we
-- just try the first argument first and the second argument second.
ws <- coercibleWanteds env a b <|> coercibleWanteds env b a

This comment has been minimized.

@LiamGoodacre

LiamGoodacre Jun 19, 2018

Member

Shouldn't these be a' and b'?

@LiamGoodacre

This comment has been minimized.

Copy link
Member

LiamGoodacre commented Jun 19, 2018

Just tried this situation and got no instance found via gToGN:

-- existing tests
data G a b = G (a (Phantom2 b))
gToG :: G Maybe Int -> G Maybe String
gToG = coerce

-- new stuff
newtype OptionIn a = OptionIn (Maybe a)
newtype OptionOut a = OptionOut (Maybe a)
gToGN :: G OptionIn Int -> G OptionOut String
gToGN = coerce
@LiamGoodacre

This comment has been minimized.

Copy link
Member

LiamGoodacre commented Jun 19, 2018

Following from @kcsongor's earlier comment:

data MutI f a = MutI a (Maybe (f a))
newtype MutA a = MutA (MutI MutB a)
newtype MutB t = MutB (MutI MutA t)

mutAToMutB :: MutA Int -> MutB Int
mutAToMutB = coerce

Gives the error:

          Type class instance for
            Prim.Coerce.Coercible (MutI MutA t1) (MutI MutB t0)
          is possibly infinite.

        while solving type class constraint
          Prim.Coerce.Coercible (MutI MutB t0) MutB
@LiamGoodacre

This comment has been minimized.

Copy link
Member

LiamGoodacre commented Jun 19, 2018

The following causes the compiler to loop and eat all your memory 😸

newtype X a = X (X (X a))
xToY :: X Int -> X (X Int)
xToY = coerce
@LiamGoodacre

This comment has been minimized.

Copy link
Member

LiamGoodacre commented Jun 19, 2018

Not sure if that previous one is a bug with this or not. As the following also loops:

newtype X a = X (X (X a))
xToY :: X Int -> X (X Int)
xToY = ?ohNo

Will make a new issue for this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment