-
Couldn't load subscription status.
- Fork 1.4k
ZIO Test: Support Laws for Higher Kinded Types #3326
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
Conversation
| import zio.test.Gen | ||
|
|
||
| trait GenF[-R, F[_]] { | ||
| def apply[R1 <: R, A](gen: Gen[R1, A]): Gen[R1, F[A]] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is really nice!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possibly this is another place where we might need to propagate the instances required of A.
Naively:
trait GenF[-R, F[_], Caps[_]] {
def apply[R1 <: R, A: Caps](gen: Gen[R1, A]): Gen[R1, F[A]]I'm thinking of the situations where, in order to create F[A], the A itself must satisfy some properties.
Or maybe there's a simpler way.
| object Covariant extends LawfulF.Covariant[Covariant] { | ||
|
|
||
| val identityLaw = new LawsF.Covariant.Law1[Covariant]("identityLaw") { | ||
| def apply[F[+_]: Covariant, A](fa: F[A]): TestResult = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In general in order to perform these checks, we would require type class instances for the parametric A, such as Equal, for example. This to me suggests maybe Law1 etc should be parameterized by two things:
The higher-kinded type class (in this example, Covariant), and the set of type classes that must be satisfied by the parametric types (e.g. Equal).
Although that does raise other complications, such as where do these types come from. If the user has control of the type classes, then they also need to have control over the "poly" gens for the types.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes I was thinking about that as well. I think the way we solve it is with EqualF, which gives us a way to determine whether two F[A] values are equal given a way to determine whether two A values are equal. I don't think it is enough just to have a way to determine whether two A values are equal because we don't know how to compare the F[A] values. We could implement this similarly to how we did GenF.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do think we need EqualF and friends.. then we can compare the F[A] given an Equal: A. But where will we get that Equal[A]? The function is polymorphic in A so has no structure on A. So it seems we need them both: propagating capabilities on the types down, but also polymorphic basic type classes like EqualF`.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. Ideally from the perspective of the caller we would like to remain fully polymorphic in the A type. Potentially this aspect of the functionality could best be provided along with the library that defines the type classes. Then we could say "the law has to operate on abstract types A, B, and C and the only guarantee we will give you is they have this structure". And then under the hood the test framework just uses something like integers that have all this structure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you are right. The downside of that is I think that means we will need the caller to provide us with a generator of A values with the required capabilities. We will then take it and hide the actual type in everything we do but we need some way to generate values with the required capabilities.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, agreed. And it's unfortunate, but it kind of makes sense, if you think about constrained functors, for example, you can only map them to specify types (e.g. types that are serializable), so the user is going to have to ultimately provide the gens for values that have the required properties, but like you say, we can hide it in the actual laws.
I briefly thought about having something like G <: GenPoly. If you propagated that in sufficient places, you could put the type class instances in there. Maybe not quite as nice to use from a syntactic perspective, because the instances aren't implicit, but maybe better integrated with the GenPoly machinery.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes that is definitely something we could explore.
| * Constructs a law from a pure function taking two parameters. | ||
| */ | ||
| abstract class Law2[-Caps[_[+_]]](label: String) extends Covariant[Caps, Any] { self => | ||
| def apply[F[+_]: Caps, A, B](fa: F[A], fb: F[B]): TestResult |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder what all the tradeoffs are between F[A] and F[B], versus two F[A]. Probably n different types is fine, as long as they all satisfy the same instances required by the laws.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this implementation gives us the best of both worlds because at least right now all the underlying types are integers but the users and authors of laws aren't allowed to know anything about the types. So I think it makes sense to have them all be different to force the user to put the types together in the right way.
|
|
||
| object ZLawsF { | ||
|
|
||
| sealed trait Covariant[-Caps[_[+_]], -R] { self => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we end up with something like:
| sealed trait Covariant[-Caps[_[+_]], -R] { self => | |
| sealed trait Covariant[-CapsF[_[+_]], -Caps[_], -R] { self => |
So you can do things like Covariant[Foldable, Equal, Any], which would say, my Foldable laws can operate on any A for which Equal is defined.
Or maybe there's a simpler way.
|
@jdegoes Thanks for the comments! I think I have a good direction on this. Will come back with another revision shortly. |
|
@jdegoes Updated to support required capabilities for the parameter type. |
|
I think it's ready for us to test it out, and if we find any problems downstream, we can always tweak it more at this point. Thanks for your work on this! |
Currently our laws functionality can support laws for type classes such as
Equalthat are parameterized on a concrete type but not for type classes that are parameterized on higher kinded types such asZippable.To address this, this PR begins by implementing a
GenF[R, F[_]], which represents a way of creating a generator of values of typeF[A]given a generator of values of typeA. Note that we need to create a new trait for this because we need to universally quantify overA.We then say that a
ZLawsF[R, F]knows how to, given a way of lifting generators of concrete values into the target type, return a test result. A variety of convenience methods are provided for creating these analogous to the ones forZLaws.I included an example of using this functionality to test an instance for a covariant functor.
One further complication is that so far our laws have always been generated from functions of one or more values of the original type. But for the composition law we need to generate functions as well. I implemented two proposed alternatives. The first is very generic, allowing the user to return a test result from a set of generic generators, but requires more work from the laws author. The second just adds a specialized constructor for this "One F[A], two functions" case. This is extremely ergonomic for the writer of the laws but more specialized. Still, there are probably not that many common pattern in laws.
There may be an intermediate version where the user provides a function from arbitrary generators and a
GenFto the generators they need, and then a function from the values returned by those generators to a test result.