Extension methods and type classes

I find extension methods in type classes a little bit cumbersome at the moment.

Here’s what I believe is the common way of using them:

trait Functor[F[_]]:
  extension [A](lhs: F[A]) def map[B](f: A => B): F[B]

given Functor[Option] with
  extension [A](lhs: Option[A]) def map[B](f: A => B) = lhs.map(f) 

This suffers from two flaws.

The first one is mostly a matter of opinion: instance declaration sites have to jump through unnecessary hoops. They really don’t care that map is an extension method, and it makes the code more verbose than necessary. I would personally prefer something that might be a little more unpleasant at the trait declaration (written once) and a little bit lighter at the instance declaration (written, hopefully, many times).

The second one is probably a bug: it breaks SAM type inference, as pointed out by Nadav Wiener:

trait Semigroup[A]:
  extension (lhs: A) def combine(rhs: A): A

given bad: Semigroup[Int] = _ + _
// Wrong number of parameters, expected: 1

I’ve been toying with a different way (spoiler warning: it ends up being even less practical): an abstract method, and a concrete extension method that proxies calls to the abstract one.

For example:

trait Functor[F[_]]:
  def fmap[A, B](fa: F[A])(f: A => B): F[B]

  extension [A](lhs: F[A]) def map[B](f: A => B): F[B] = fmap(lhs)(f)

given Functor[Option] with
  def fmap[A, B](oa: Option[A])(f: A => B) = oa.map(f)

It does make instance declaration more pleasant, but runs into a different problem: you can’t have a regular method and an extension one with the same name. This forces me to find two names for a given method, which is basically a show stopper.

Diego Alonso proposed a different strategy - making the extension method syntax non-compulsory in subclasses.

4 Likes

The problem there is that you get this code where A and lhs just appear out of thin air:

given Functor[Option] with
  def map[B](f: A => B) = lhs.map(f) 

There may be some value in allowing to override extension methods with regular methods:

given Semigroup[Int] with
  def combine(lhs: Int)(rhs: Int) = lhs + rhs

Currently that gives you an error ... is a normal method, cannot override an extension method. But a bigger problem is that there’s not even any syntax to express the “normal method” version of map in Functor with 2 type param lists.

I personally thought the original proposal for extension methods with infix syntax (def (lhs: Int) combine (rhs: Int): Int) felt a bit lighter, but I guess it was eventually considered either too confusing or—funnily enough—too cumbersome and verbose to repeat the lhs in every definition.

How about this:

trait Functor[F[_]](fun: [A, B] => (F[A], (A => B)) => F[B]):
  extension [A](lhs: F[A]) def map[B](f: A => B): F[B] = fun[A, B](lhs, f)

given Functor[Option]([A, B] => (opt: Option[A], f: A => B) => opt.map(f))


trait Semigroup[A](fun: (A, A) => A):
  extension (lhs: A) def combine(rhs: A): A = fun(lhs, rhs)

given Semigroup[Int](_ + _)

?
This already works without any changes to the compiler.

2 Likes

I suggest doing something like:

trait Semigroup[A]:
  extension (lhs: A) def combine(rhs: A)

object Semigroup:
  def instance[A](combineOp: (A, A) => A): Semigroup[A] = new:
    extension (lhs: A) def combine(rhs: A) = combineOp(lhs, rhs)

given Semigroup[Int] = Semigroup.instance(_ + _)

Cats for example follows this pattern already: https://github.com/typelevel/cats/blob/6727c6c39c59abf278908d64c252a4c6fcaa8d1d/kernel/src/main/scala/cats/kernel/Semigroup.scala#L148-L154

7 Likes

@nrinaudo

I believe it should be written this way:

trait Foonctor[F[_]]:
  def foo[A,B](fa: F[A], f: A => B): F[B]

  extension [A,B](lhs: F[A])(using Foonctor[F]) def foo(f: A => B): F[B] = summon[Foonctor[F]].foo(lhs,f)

end Foonctor
  

given Foonctor[Option] with
  def foo[A,B](fa: Option[A], f: A => B) = fa.map(f) 

Option("a").foo(_.toUpperCase)

Typeclass has an abstract method and an extension method is defined only once.

1 Like

So, although I first suggested the “inherit syntax” idea, after reading the thread I have had another idea. I start from Nicolas Rinaudo two-method solution and its downside:

trait Functor[F[_]]:
  def fmap[A, B](fa: F[A])(f: A => B): F[B]

  extension [A](lhs: F[A]) def map[B](f: A => B): F[B] = fmap(lhs)(f)

It does make instance declaration more pleasant, but runs into a different problem: you can’t have a regular method and an extension one with the same name. This forces me to find two names for a given method, which is basically a show stopper.

Why do we need the extension method to be called differently? I guesss because they are syntactic sugar. The compiler desugars extension (lhs: F[A]) def map(f: A => B) into a normal def map(lhs: F[A])(f: A => B) declaration, and any call such as Some(12).map(_ + 1) into Functor[Option].map(Some(12))(_ + 1). Thus, Functor cannot have the regular fmap method and the map extension method with the same name, just it wouldn’t two regular methods.

However, in this case (and that of other typeclasses) we do not seek to have two methods. We just want one single method that we can use either as a regular method or as an extension method. A simpler way, in keeping with the “intention over mechanism” theme, is to express that intention with an also known as clause:

trait Functor[F[_]]: 
  def map[A, B](fa: F[A])(f: A => B): F[B] 
    aka extension(fa).map(f)

given Functor[Option] with 
  def map[A, B](fa: Option[A])(f: A => B) = oa.map(f)

The extension alias (for lack of a better name) would be exported from the abstract declaration. The alias does not need to redeclare the value or type parameters, since they are part of the main declaration.

A method could have several extension alias declarations. This would also give a way of declaring symbolic operators . Furthermore, those additional methods could change the order of the parameters:

trait Monad[F[_]] {
  def flatMap[A, B](fa: F[A])(k: A => F[B]): F[B]
    aka extension(fa).flatMap(k)
    aka extension(fa).>>=(k)
    aka extension(k).over(fa)
    aka extension(k).=<<(fa)

  def pure[A](a: A): F[A]
    aka extension(a: A).pure[F]
}

Note extension methods may already be called directly instead:

trait Functor[F[_]]:
  extension [A](fa: F[A])
    def map[B](f: A => B): F[B]
    
def incrementViaExtension[F[_]: Functor](xs: F[Int]) =
  xs.map(_ + 1)

def incrementViaDirect[F[_]: Functor](xs: F[Int]) =
  summon[Functor[F]].map(xs)(_ + 1)

This is official syntax per Martin, not taking advantage of the encoding: https://github.com/lampepfl/dotty/issues/9880#issuecomment-913671596

One other thing to consider if you decide to define extension methods concretely and delegate to an abstract member: https://github.com/lampepfl/dotty/issues/12126

4 Likes

nice syntax, then my example can be rewritten as


trait Foonctor[F[_]]:
  extension [A](fa: F[A])
    def foo[B](f: A => B): F[B]
  

given Foonctor[Option] with
  extension [A](fa: Option[A])
    def foo[B](f: A => B): Option[B] = fa.map(f)

Option("a").foo(_.toUpperCase)

summon[Foonctor[Option]].foo(Some("a"))(_.toUpperCase)

How does it interact with import ?

Should we just import Foonctor and the extension will be available ?