Dotty Type classes

So I guess the identity value for min is 2,147,483,647.

So while I’ve long wished that Functor and Monad were part of the standard library, but been sceptical of the value of Monoid and Semi Group instances. So I thought I’d see what Cats had to say.

val l = List(1, 2, 3, 4, 5)
l.foldMap(i ⇒ (i, i.toString))
//(15,12345)

Having a default combine operator for String is an even worse idea than for Int. Composing semigroups and monoids seems like a bad idea, because the more monoid instances you compose over the less chance that you actually want all the defaults.

In practice the default instances end up being what you usually want. It’s a pragmatic decision but as a longtime user of this stuff I think it’s the right call. I very seldom want a different one, and when I do I use a newtype and it’s fine.

Note that in some cases like Boolean there’s no obvious way to pick a canonical instance so we just don’t have one.

3 Likes

Just to emphasise the point I’ve changed the values:

val l = List(81, 2, 12, -15, 0, 76, -3)
val l2 = l.foldMap(i ⇒ (i, i.toString))
println(l2) 
//(153,81212-15076-3)

For instances to be coherent they have to be placed either in the companion object of the type class or the companion object of the data type the instance is defined for. We should still allow local instances that can be brought in scope like they can today, but the compiler should at least emit a warning.

This is not enough for coherence. You also need to have an overlapping instance checker.

Here is what I want to see from the orphan checker:

  1. Guarantee that whenever someone implicitly summons a typeclass, it’s always the same instance (coherence).
  2. Being able to do (e: Eq[A]).contramap when declaring implicit instances.
  3. Being able to operate with an a: Eq[A] as if it’s just a record as long as you don’t export it implicitly.
  4. Being able to unsafely turn a: Eq[A] into an implicit val a : Eq[A].

This is considerably better than what Haskell does, because typeclasses become first-class values (for the most part).

It’s sort of TcEq[A] <: Eq[A], where implicits must have type TcEq[A]. Upcasts are free, downcasts are only visible in the companions. You can (a : Eq[A]) : TcEq[A] @orphan (or something to that effect) to unsafely downcast. (TcEq[A] and Eq[A] should be visible to the user as the same type, but from the POV of the typechecker, all implicit val eq: Eq[A] are actually typed as TcEq[A])

Being able to unsafely turn a dictionary into a typeclass gives you reflection / local instances a la reflection/examples/Monoid.hs at master · ekmett/reflection · GitHub (ideally you also need polymorphic values for Rank-2 polymorphism, but we can do without).

5 Likes
type class Semigroup[A] {
  extension def combine(this x: A)(y: A): A
}

type class Monoid[A] : Semigroup[A] {
  def empty: A
}

If a more conservative syntax is used, we might be able to do this with

trait Semigroup[A] extends Typeclass {
  def combine(@this x: A)(y: A): A
}

trait Monoid[A: Semigroup] extends Typeclass {
  def empty: A
}

in Scala2 with scalaz-plugin (https://github.com/scalaz/scalaz-plugin).

5 Likes

I think there shouldn’t be a Monoid for min. Just a semigroup. If you want a monoid, wrap your type in Option. Also, what’s wrong with append as the concat operation for String?

I like the idea of coherent typeclasses and using opaque types for custom instances.

I don’t think it’s fruitful to discuss what exact typeclasses and which variants of them to implement in the Standard Library, at least not in this thread. We should focus exclusively on the feature set of typeclasses in general, to make them easier to define and use in all cases.

7 Likes

Min is a monoid if your type is upper bounded (such as Int.MaxValue, Long.MaxValue, etc…)

It’s actually a BoundedMeetSemilattice: https://github.com/typelevel/algebra/blob/master/core/src/main/scala/algebra/lattice/BoundedMeetSemilattice.scala

Right, now I see there’s even instance (Ord a, Bounded a) => Monoid (Min a) in GHC. My original reasoning was that having a Min for integers in general doesn’t make sense because there’s no representation of integer infinity. Somehow Int.MaxValue still doesn’t sound exactly perfect to me, but I guess it’s the best thing we have :wink:

whether or not Monoids should have a TC representation or not (an interesting topic) is totally orthogonal to TC support in Dotty, and is derailing the discussion of it.

10 Likes

First of all thanks a lot for taking the lead on this proposal @LukaJCB ! I think it is very possible to debate such an important topic in a friendly way let’s keep it up :slight_smile:

Here are some thoughts I have after re-reading the proposal:

Extension methods

Introducing a new extension keyword sounds good to me :+1:

Being able to define extension methods directly in a type class definition sounds great!

Type class definitions

I would rather introduce a typeclass keyword instead of combining the two existing ones type and class because I think that’s confusing for people learning the language. Would this represent a big challenge?

I very much agree that type classes definitions should not permit inheritance. This will tremendously help with coherence.

Not sure having override extension is a good idea but I agree it is sometimes necessary to override default implementations (eg. flatMap) for performance and stack safety reasons but right now I can’t think of any better idea.

Instance declarations

I would rather introduce an instance keyword for this. I find the use of the extension keyword as the valid way to declare type classes instances very confusing since it’s also used to declare extension methods (AKA syntax).

Type class usage

LGTM :+1:

Coherence and multi-param type classes

Overall I think we need more details and examples on this part. AFAIU I think it should be fine if the compiler can determine the type class instance by following the associated types as proposed.

1 Like

type class would harmonize with case class if not a problem with different inheritance notation. New keyword is also pretty big deal.

as i know extension keyword is planned anyway. instance would be new one.

I’m not big fan of introducing such big chunk of syntax for things that can be encoded with other tools, and are not used super widely. Is there such big need for defining typeclasses?
Creating instances could be more important but still we should try to keep syntax as close current scala as possible.

What about alexknvl more conservative syntax proposition?
I know that it is not exhaustive proposition but maybe it can be polished?

Scala has type members. I still don’t get why those multi-parameter typeclasses don’t use them.

trait Collection[C] { // or whichever syntax you prefer
  type E
}

trait MonadError[F[_]] {
  type E
}

I’m unsure about your wording, since your wording implies you talk about individual call sites, but in general guaranteeing coherence is not possible, especially due to dynamic linking. It’s very hard to guarantee that an Eq[A] wasn’t defined multiple times in any of the project’s dependencies and having such a guarantee in the project’s own source files is a pretty weak guarantee to have.

IMO a warning is enough. Coherence is mostly achieved by convention and that’s OK. Even in Haskell sometimes you just need orphans (e.g. JSON serialization).

1 Like

Scala has type members. I still don’t get why those multi-parameter typeclasses don’t use them.

Because it doesn’t work. E.g. a type member doesn’t work for MonadError since obviously you can have multiple MonadError instances defined for each (F[_], E) tuple you care about and its behavior can change depending on E. Not many such instances in the wild, but type members and type parameters are apples vs oranges.

And the E type is also exposed in the function signatures, which means you lose type information about it when working with the generic MonadError[F] interface. You can only materialize E when the compiler knows the exact instance you’re using, otherwise the type is going to be MonadError[F]#E, which is pretty useless and annoying.

I’d like to point out that … if we are going to introduce type classes, it’s very important to look at actual usage in the wild. We need multiple type parameter classes, because multiple type parameter classes have been in use for a long time. In Cats-Effect too we have been discussing multi-param type classes in order to accommodate “bifunctor IO” implementations.

5 Likes

Yes, there’s an entire ecosystem that uses type classes, see the projects at Typelevel.org.

Type classes as a concept are equivalent with implicit parameters (which is why Scala is an awesome language for FP) and, in comparison with OOP subtyping, type class restrictions play better with generic type parameters and static typing.

As Luka is explaining, nowadays we have to do a lot of boilerplate in order to encode these concepts. And we can use macros in order to reduce that boilerplate (see Simulacrum), but AFAIK such libraries will no longer be possible on top of Scala 3 / Dotty due to no longer having such macros capabilities.

Book authors like the authors of Scala with Cats have complained that beginners have a really hard time grasping FP concepts due to type classes being so hard to understand due to the extreme boilerplate and ceremony involved.

Scala is a language that has thrived on its HKTs and ability to define and use type classes. That’s one of the features that make Scala shine in comparison with other languages. It’s time to make that official.

And it would be a shame if Kotlin got type classes first.

6 Likes

But you cannot have MonadError instances for any (F[_], E) tuple. For IO it’ll always be Throwable. For IO[E, ?] it’ll always be E. For Either[E, ?] it’ll always be E. And ok, for EitherT[IO, E, ?] I guess it’ll be one of Throwable or E. But it’s still determined by F. For EitherT you’ll just have to give priority to one of both instances, and use an orphan instance if you want to use the other, kind of like when you want a * Monoid instead of the default +.

That’s exactly my point, actually. If one type is determined by the other, I think that a type member is a better fit.

Currently you can use the Aux pattern when you need to be able to know inside your method that e.g. E =:= String. If we’re scrapping boilerplate for typeclasses, you could consider boilerplate free Aux as well.

@Jasper-M You proposal breaks for polymorphic code. Consider this:

def foo[F[_]](implicit F: MonadError[F, Throwable]): F[String] = 
   F.raiseError(new Exception("problem")).handleError {
        case e => e.getMessage
  }

val a = foo[IO]

if you used a type member for MonadError, we’d have:

def foo[F[_]](implicit F: MonadError[F]): F[String] = 
   F.raiseError(new Exception("problem")).handleError {
        case e => e.getMessage // Doesn't compile
  }

because we lose info about which E we are talking about. To recover that you need the Aux pattern

object MonadError {
 type Aux[F, Err] = MonadError[F] { type E = Err }
}

so that we can implicit F: MonadError.Aux[F, E], at which point it’s just better to use two type parameters instead. There are multi param type classes that benefit from type members (cats Parallel for example), but MonadError isn’t one of them imo: it only makes sense to hide E when you are dealing with a concrete type, but in that case you don’t need a typeclass constraint at all, and when you do, you’d have to use Aux anyway, so it really makes more sense to have two type params there.

Note that the example you made in your comment here, i.e.

def bar[F[_]: MonadError](f: F[String]) = ???

Doesn’t really work: polymorphic code that calls bar cannot recover from the error, because it has no information about E unless: F is instantiated to a concrete type (you lose polymorphism), or bar is changed to use Aux (no benefit from having a type member)


Furthermore, there are still cases where a multi param type class still needs two or more type params, even with type members, for example:

class Mult a b c | a b -> c where
  (*) :: a -> b -> c
 
instance Mult Matrix Matrix Matrix where
  {- ... -}
 
instance Mult Matrix Vector Vector where

Here c can be a type member, but a and b make sense as type parameters. Shapeless provides countless real-world examples of this

3 Likes

@scalway

type class would harmonize with case class if not a problem with different inheritance notation. New keyword is also pretty big deal.

If adding a new keyword represents such a big challenge I completely understand. I just wanted to point out how confusing it might sound for newcomers:

  • type: to define type aliases. Eg type Username = String
  • class: to define and instantiate classes. Eg. class Foo() and val foo = new Foo()
  • type class: to define a type classes. Eg. type class Monoid[A] : Semigroup[A] {}

Is there such big need for defining typeclasses?

Yes. As @alexandru pointed out there’s an entire ecosystem making use of them and we would benefit from having an easier way to work with them.

Creating instances could be more important but still we should try to keep syntax as close current scala as possible

Changes are already being made (opaque types, type lambdas, etc) and I think it’s for the better. Keeping syntax as close as current Scala is secondary IMHO.

What about alexknvl more conservative syntax proposition?

His second (more conservative) option looks pretty similar to what we can do today in Scala 2 and I can’t see how type classes syntax could be defined. However, I believe we can do better for Scala 3 by having native support for type classes.

BTW I think it’s great Alex is managing to get that to work in Scala 2 with the scalafix plugin!

4 Likes