advancedtelematic / quickcheck-state-machine

Test monadic programs using state machine based models

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Factor out hfunctor and friends into its own package

stevana opened this issue · comments

As per discussion in #161.

Thinking about the abstraction behind HFunctor.

Originally, HFunctor is the class of functors between two functor categories (as it appears in a category-extras module for instance, now vanished from Hackage).

hfmap :: HFunctor t => (forall x. u x -> v x) -> (forall a. t u a -> t v a)

But hfmap alone doesn't quite express the intent that t :: (* -> *) -> (* -> *) maps functors to functors. We would also need something like forall u. Functor u => Functor (t u).


In quickcheck-state-machine, the user-defined type Action v is not a functor, so Action is not an HFunctor in the sense above. I mean, the fact that we can implement and use HFunctor for Action is somewhat of a coincidence, thanks to a lack of expressiveness of the constraints language in Haskell.
So our HFunctor abstraction is actually a bit ad-hoc, and I hope that we can decompose it into elements that are easier to reuse as part of their own package.

The variable a is "second-class" in that there aren't interesting ways to map over it, and I would be tempted to change the order to Action a v, making a more "natural" signature be:

hfmap' :: HFunctor' f => (forall x. u x -> v x) -> f u -> f v

This HFunctor' represents functors between a functor category and Hask (f :: (* -> *) -> *).

Note that hedgehog's HTraversable follows that pattern for the kind of f:

-- From hedgehog
htraverse :: (HTraversable f, Applicative m) => (forall x. u x -> m (v x)) -> f u -> m (f v)

To make the kinds match, we would need to write HFunctor' (Action a). However, the combinators in quickcheck-state-machine rely on a parametric instance, informally forall a. HFunctor' (Action a). Three ways to do it:

  • Create a new type class, an "indexed" variant of HFunctor':

      hfmap1' :: HFunctor1' (t :: * -> (* -> *) -> *) => (forall x. u x -> v x) -> t a u -> t a v
    

    The current way we are doing things can be seen as (ab)using HFunctor to play that role.

  • Use ForallF from the constraints package: ForallF HFunctor' Action represents the universally quantified constraint forall a. HFunctor' (Action a).

  • Wait for Quantified contexts to land (who knows when). (GHC issue, ICFP'17 paper)

Neither choice is very satisfying. I guess the HFunctor1 would be nicer for us, while the ForallF route would be better when taking Hedgehog into consideration (which we should).

The first release of quickcheck-state-machine used to have ForallF constraint in the types, and I think it made the types look more intimidating. I guess things are better now with the actual instances being hidden away with template Haskell, but still...

@jystic: Thoughts?

I would only say that I don't mind too much if there is extra cruft in the internal usage of the abstraction, but I would prefer that the way clients use the API is as clean as possible. As such the ForallF approach, if I understand it correctly, isn't that satisfying.

@stevana in quickcheck-state-machine's Action v a, what does the a represent?
Ignore me! I see now that's it's just got the arguments flipped compared with Var in hedgehog

The user still writes almost the same code with ForallF. It's just that the instance declaration changes from HTraversable Action to HTraversable (Action a), and this becomes invisible if they use TH.

We could define a "quickcheck-state-machine"-specific HTraversable1 on top of a more general HTraversable:

  • either as a type class synonym HTraversable1 = ForallF HTraversable, which hides ForallF from the main signatures, and also gives us a good place to document how that works for users who wonder about the mismatch between the HTraversable they write vs the HTraversable1 that's required of them;

  • or we can avoid the Forall hack, with a custom type class and a default implementation.

    class HTraversable1 t where
        htraverse1 :: Applicative m => (forall x. f x -> g x) -> t a f -> m (t a g)
    
        default htraverse1 :: (HTraversable (t a), Applicative m) => (forall x. f x -> g x) -> t a f -> t a g
        htraverse1 = htraverse

    It's a bit more boilerplate, but users that are not interested in all the cruft with HTraversable can also choose to only write HTraversable1 (and HFoldable1 and HFunctor1 if they're still around) as they do currently.

Now that you explain it, both solution sound pretty reasonable, especially if the hard work is hidden behind TH. Do you have a preference?

I prefer ForallF.

(I'm fine with either.)

I'm ok with ForallF, especially if we can use a type synonym

Another thing worth considering: how much does the GADT give us? Could it be worth scraping it, so we can use Generics rather than template Haskell? Or could we have both by having template Haskell derive an untyped (non-GADT) version of the action datatype (for which we have generics), and two convertion functions between the GADT and non-GADT version of actions?

I just found that the rank2classes has the higher-order Traversable already. But @stevana you raise a good point.

We ended up going for the non-GADT approach together with a generic version of rank2classes in #209, see: https://github.com/advancedtelematic/quickcheck-state-machine/blob/master/src/Test/StateMachine/Types/Rank2.hs