-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Add Order2 And Friends TypeClasses #4061
base: main
Are you sure you want to change the base?
Conversation
This commit adds the following type classes. * Order2 * Order1 * PartialOrder2 * PartialOrder1 * Hash2 * Hash1 * Eq2 * Eq1 These typeclasses lift their base classes through unary and binary type constructors. See https://hackage.haskell.org/package/base-4.15.0.0/docs/Data-Functor-Classes.html#t:Ord2 . A full documentation writeup is provided in `order1.md`. Here is a brief overview. These typeclasses can make defining instances for types which are either unary or binary type constructors much more succinct. This is because there is no longer a need to provide lower priority instances for `PartialOrder` and `Eq`. For example, an instance of `Order1` for some type `F[_]` can provide a `PartialOrder[F[A]]` given some `PartialOrder[A]` even if `A` doesn't have an `Order` instance. See the `order1.md` for some examples using `Option`. See the provided instance for `Either` for a concrete example. In addition to removing the need for lower priority instances using the higher kinded variants of these classes can make the expression of some constraints much more succinct. For example, consider this data type definition. ```scala final case class Person[F[_]]( name: F[String], age: F[Int], height: F[Double] ) ``` In the absence of `Eq1`, if one wished to provide an `Eq` instance for `Person[F]` then they would need to use the following definition. ```scala def eqInstanceForPerson[F[_]](implicit A: Eq[F[String]], B: Eq[F[Int]], C: Eq[F[Double]]): Eq[Person[F]] = Eq.instance{ case (x, y) => x.name === y.name && x.age === y.age && x.height === y.height } ``` That is, they would need to demand an `Eq[F[A]]` for each distinct `A` in the data type. With `Eq1` one can now write this. ```scala implicit def eqInstanceForPerson[F[_]: Eq1]: Eq[Person[F]] = Eq.instance{ case (x, y) => x.name === y.name && x.age === y.age && x.height === y.height } ``` Now you only need to demand an `Eq1` instance for `F[_]`. An instance of `Order2` and `Hash2` was added for `Either` and the other `Order`, `PartialOrder`, `Eq`, and `Hash` instances were deprecated. This serves as a convenient example. Misc Other Changes --------------------- Bumped version to 2.8.0-SNAPSHOT due to the new symbols. (I know, I just missed the 2.7.0 release :( ) Future Work ------------- If this PR is accepted, then I intend to also add the following. * Additional combinators and methods to all the classes, analogous to the combinators and methods on the base classes. * Add instances for the other cats/stdlib types. This should permit a non-trivial reduction in code size when the deprecated instances are removed in 3.0.0. Weird Things -------------- In order to be able to derive an `Order` instance from an `Order1` instance, a low priority implicit was required in the companion object scope of `Order` (and similarly for the other classes). This means that these new classes needed to be in `kernel`, not core. This has had the following consequences. * Some of the instances in core require type projection that would normally be done with kind-projector. Because kind-projectors is not currently available in `kernel`, I had to write the projection in the more awkward base Scala syntax. * In order to be able to derive all the instances we would expect, an instance of `Order1` (and friends) is needed for `cats.Id`. However, `cats.Id` is defined in the package object in `core`, not _kernel_. However, we were able define instances for `cats.Id` by just using a type projection. Initially, I didn't think that was going to work, but to my surprise the compiler figured it out.
I'm looking into the build failure, but I don't think it is related to this PR. |
I'm not sure this solves the low priority instance because |
I am not sure we should add these types. I think we should see a strong case for adding something, not an example or two. It's not that they don't make sense, but the question is: do the use cases justify adding this surface area to cats? I so far don't see that. Keep in mind: there are many libraries outside of cats (cats-effect, cats-collections, cats-time, and many more). Not everything needs to be upstreamed to cats. Even if we do add it, they should not be added to kernel. What is and is not in kernel is up for debate, but the motivation for kernel was to share classes that were very broadly used. So kernel shares with algebra which also shares with spire and algebird. Another observation is that none of the typeclasses themselves are on type-constructors in kernel, and are just on types (I think that's still true). If we want these, they should be in cats since I don't see the demand coming to have these types in those other projects. |
@joroKr21 I'm not certain I follow what you mean. Given this trivial type. final case class Wrapper[A](value: A)
object Wrapper {
implicit val instance: Order1[Wrapper] with Hash1[Wrapper] =
new Order1[Wrapper] with Hash1[Wrapper] {
override def liftCompare[A, B](compare: (A, B) => Int, x: Wrapper[A], y: Wrapper[B]): Int =
compare(x.value, y.value)
override def liftHash[A](hash: A => Int, x: Wrapper[A]): Int =
hash(x.value)
}
} I am able to derive
If you define the instances for |
Ah I see what you mean now - yes that would work. I must admit I almost never used this trick in application code so it didn't immediately pop in my mind. |
For my part, I think the potential code reduction is strong motivation. For most types which cats provides I also think removing the need to rely on implicit priority for these instances could help to make their encoding a bit more accessible, though that is admittedly a subjective measure. I do personally find the higher kinded data use case quite compelling. Admittedly, I don't see encodings like
I started out with these in This could of course be worked around to a degree with some orphaned implicits, but we also lose the ability to drop the aforementioned code in kernel and the ergonomics become much less appealing. I agree, we should be very conservative about what goes in kernel. However in this case the utility and ergonomics of these classes drops quite a bit if they are anywhere other than kernel. |
(orderInstanceForOption[Map[String, String]]: Eq[Option[Map[String, String]]]) | ||
``` | ||
|
||
This fails, because while `Map[String, String]` _does_ have an `Eq` instance, it doesn't have an `Order` instance but our instance demands that `A: Order`. Classically the solution to this problem one would use Scala's lower priority implicit behavior to provide multiple instances, in this case we'd need to provide one for `PartialOrder` and `Eq`, having 3 different implicit scopes. |
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.
Are the 3 scopes actually needed here? Order[Option[Int]] <: Eq[Option[Int]] so if both are in the same implicit scope, the compiler will pick the one with the most precise type.
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.
Huh, I had thought they needed to be defined that way, but my local tests show you are correct.
Do you know why we doing this then? Three definitions, each in a lower priority scope than the last.
https://github.com/typelevel/cats/blob/main/kernel/src/main/scala/cats/kernel/instances/EitherInstances.scala#L6
https://github.com/typelevel/cats/blob/main/kernel/src/main/scala/cats/kernel/instances/EitherInstances.scala#L54
https://github.com/typelevel/cats/blob/main/kernel/src/main/scala/cats/kernel/instances/EitherInstances.scala#L79
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.
No idea.
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'll update that writeup shortly. Thanks for the insight!
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.
updated
val Eq1 = cats.kernel.Eq1 | ||
val Eq2 = cats.kernel.Eq2 | ||
val Hash1 = cats.kernel.Hash1 | ||
val Hash2 = cats.kernel.Hash |
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.
A typo maybe? Shouldn't it be val Hash2 = cats.kernel.Hash2
instead?
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.
haha, yep! Thanks! I'll fix that up.
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.
fixed
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 guess the reason why it slipped through is that there were no tests for that ;)
* | ||
* @see [[https://hackage.haskell.org/package/base-4.16.0.0/docs/Data-Functor-Classes.html#t:Eq1]] | ||
*/ | ||
@implicitNotFound("Could not find an instance of Eq1 for ${F}") |
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.
See #4060. I guess it was decided to get rid of the @implicitNotFound
annotation unless it is absolutely necessary.
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'll drop it.
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.
dropped
Thanks! that would be helpful. I don't see the savings since it feels like we are just replacing Secondly, I can't even imagine why we would want heterogeneous inner types: |
Indeed, I was on the fence about this too, however there is prior art here: https://hackage.haskell.org/package/base-4.16.0.0/docs/Data-Functor-Classes.html#t:Eq1 (In particular see the doc for As for laws, I was also on the fence about this. Should we have laws for these classes directly, or should we say that they are lawful in the context of the
I'll get you some concrete numbers shortly, but it should be noted we aren't just replacing |
Here is that back of the napkin math I promised you. If we removed the deprecated instances for Either (Order[Either[A, B]], PartialOrder[Either[A, B]], Hash[Either[A, B]], Eq[Either[A, B]]), then we end up with a net reduction in code of 87 lines. Our Now find types of the form
That's 18 types that I could reasonably find. There are a few other types which might have some more minor improvements, such as Binested, but let's ignore those for now. That all adds up to a savings of about 18*50 = 900 lines of code. |
Thanks for that summary. You didn't include the lines in this PR right? (about 900 lines, which interestingly matches the savings), but also we can't remove the old values due to binary compatibility guarantees. So, won't the end product have 900 + 900 extra lines? |
@johnynek that's true, but 247 of those lines are my
I'm suggesting this is a savings for cats 3.0.0 when we can remove them. |
Alternatively, I imagine cats 3.0.0 could use https://docs.scala-lang.org/scala3/reference/contextual/derivation.html which might also save people from writing all these instances 😁 |
@smarter While Using contextual derivation might also reduce lines of code, it doesn't solve the other problems |
Which other problems? The documentation mentions avoiding the need for multiple implicit scopes but I don't think that's actually needed as mentioned in #4061 (comment) |
@smarter the problems these classes solve are,
|
Remove incorrect discussion about implicit scope.
/** | ||
* Lift equality tests through the type constructor. | ||
*/ | ||
def liftEq[A, B](compare: (A, B) => Boolean, x: F[A], y: F[B]): Boolean |
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.
IMO, this name liftEq
is not really apt here. This method does not lift anything into some other context (as most of other lift*
methods do in Cats) – it just does the comparison itself. Maybe it'd be worth to poke around the word compare
instead, wdyt?
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'm torn. I agree that the way we are using "lift" here is different than how we often use it. OTOH, I really like to keep names in line with any prior art and that is what Eq1
uses in Haskell. https://hackage.haskell.org/package/base-4.14.1.0/docs/Data-Functor-Classes.html#v:liftEq
If the PR is otherwise approved, and the consensus is to use a different name, then I'd be happy to change it. My preference for using the canonical name from other language's implementations is only slight here.
If we do change it, compare1
comes to mind as an option? Though I don't love digits in method names.
I'm super -1 on "well haskell has it this way" as an argument. Scala is not Haskell and different design constraints come into play. For instance, in haskell, typeclass instances are not values you program with (generally, maybe there are some tricks, but it is not idiomatic). So, if we were to include something like trait Eq1[F[_]] {
def eqFor[A](implicit eqA: Eq[A]): Eq[F[A]]
} which is doing three things:
That said, I'm still not seeing the win over |
The Update: nevermind, after skimming through #599. |
Aside: we could add deriving support to cats 2 in binary compatible way, or we could rely on kittens for derivation. Even in the latter case, we’d get support for Scala 3 derives syntax at the cost of an import. I’d prefer to bake it in to cats directly personally. |
I didn't intend to communicate we should just "do it the Haskell way". Mostly, with respect to Haskell, I thought having continuity in naming would be good. People who come to Scala and already know about this type of FP tend to come from a background in Haskell, Purescript, or Elm. I've found, both for myself and others, having continuity of naming is helpful in finding your way around and discussing constructs. That said, if it's a deal breaker, I really have only a slight preference in the naming. With respect to your proposed |
Just to clarify, are you saying it doesn't seem useful or just that the bincompat stuff is different than you had thought? |
I totally agree. The code reduction that this affords us in the definition of instances is only a part of the gain here. I'm primarily interested in being able to constrain certain types. |
tl;dr it's not useful for laws/discipline. The problem is that requiring E.g. How would you define The bincompat thing is a nightmare, but manageable. But providing an |
Note we originally had |
I agree that if this was merged, it shouldn't be used in laws to replace any |
Yes, sorry, it was not a criticism :) just that my use-case idea evaporated unfortunately. |
However, one lesson there might be that |
🤔 I'm trying to think of a situation where this would be an issue, outside generating |
I'm making this a draft for now. There appears to be some bug in the Scala 3 compiler which is causing the build to fail. It is fixed on the latest RC, but until cats is ready to release on that this is not likely to be able to be merged whatever we decide on the merits of these classes in general. |
@isomarcte now that Cats is on 3.1 seems like we could revive this. Any new thoughts about the proposal? IIRC there was not much consensus to move forward with it. |
@armanbilge sorry, started a new job recently and I've been a little behind on open source. Let me see if I can get this building now and then we can talk about next steps, be that close it or merge it or something inbetween. |
@armanbilge good news! This is building for me on Scala 3 locally now. Let's see what CI says. If it is green, then I think there are a few items to discuss. |
This commit adds the following type classes.
These typeclasses lift their base classes through unary and binary type constructors. See https://hackage.haskell.org/package/base-4.15.0.0/docs/Data-Functor-Classes.html#t:Ord2 .
A full documentation writeup is provided in
order1.md
. Here is a brief overview.These typeclasses can make defining instances for types which are either unary or binary type constructors much more succinct. This is because there is no longer a need to provide lower priority instances for
PartialOrder
andEq
. For example, an instance ofOrder1
for some typeF[_]
can provide aPartialOrder[F[A]]
given somePartialOrder[A]
even ifA
doesn't have anOrder
instance. See theorder1.md
for some examples usingOption
. See the provided instance forEither
for a concrete example.In addition to removing the need for lower priority instances using the higher kinded variants of these classes can make the expression of some constraints much more succinct. For example, consider this data type definition.
In the absence of
Eq1
, if one wished to provide anEq
instance forPerson[F]
then they would need to use the following definition.That is, they would need to demand an
Eq[F[A]]
for each distinctA
in the data type.With
Eq1
one can now write this.Now you only need to demand an
Eq1
instance forF[_]
.An instance of
Order2
andHash2
was added forEither
and the otherOrder
,PartialOrder
,Eq
, andHash
instances were deprecated. This serves as a convenient example.Misc Other Changes
Bumped version to 2.8.0-SNAPSHOT due to the new symbols. (I know, I just missed the 2.7.0 release :( )
Future Work
If this PR is accepted, then I intend to also add the following.
Weird Things
In order to be able to derive an
Order
instance from anOrder1
instance, a low priority implicit was required in the companion object scope ofOrder
(and similarly for the other classes). This means that these new classes needed to be inkernel
, not core.This has had the following consequences.
kernel
, I had to write the projection in the more awkward base Scala syntax.Order1
(and friends) is needed forcats.Id
. However,cats.Id
is defined in the package object incore
, not kernel. However, we were able define instances forcats.Id
by just using a type projection. Initially, I didn't think that was going to work, but to my surprise the compiler figured it out.