Some Name
Some Name

Reputation: 9540

Implementing functor map for class-tagged arguments only

I have the following data structure:

class MyDaSt[A]{

    def map[B: ClassTag](f: A => B) = //...
}

I'd like to implement a Functor instance for to be able to use ad-hoc polymorphism. The obvious attempt would be as follows:

implicit val mydastFunctor: Functor[MyDaSt] = new Functor[MyDaSt] {
  override def map[A, B](fa: MyDaSt[A])(f: A => B): MyDaSt[B] = fa.map(f) //compile error
}

It obviously does not compile because we did not provide an implicit ClassTag[B]. But would it be possible to use map only with functions f: A => B such that there is ClassTag[B]. Otherwise compile error. I mean something like that:

def someFun[A, B, C[_]: Functor](cc: C[A], f: A => B) = cc.map(f)

val f: Int => Int = //...
val v: MyDaSt[Int] = //...
someFunc(v, f) //fine, ClassTag[Int] exists and in scope

I cannot change its implementation in anyway, but I can create wrappers (which does not look helpful through) or inheritance. I'm free to use shapeless of any version.

I currently think that shapeless is a way to go in such case...

Upvotes: 0

Views: 125

Answers (1)

Oleg Pyzhcov
Oleg Pyzhcov

Reputation: 7353

I'll expand on what comments touched:

Functor

cats.Functor describes an endofunctor in a category of Scala types - that is, you should be able to map with a function A => B where A and B must support any Scala types.

What you have is a mathematical functor, but in a different, smaller category of types that have a ClassTag. These general functors are somewhat uncommon - I think for stdlib types, only SortedSet can be a functor on a category of ordered things - so it's fairly unexplored territory in Scala FP right now, only rumored somewhat in Scalaz 8.

Cats does not have any tools for abstracting over such things, so you won't get any utility methods and ecosystem support. You can use that answer linked by @DmytroMitin if you want to roll your own

Coyoneda

Coyoneda can make an endofunctor on Scala types from any type constructor F[_]. The idea is simple:

  • have some initial value F[Initial]
  • have a function Initial => A
  • to map with A => B, you don't touch initial value, but simply compose the functions to get Initial => B

You can lift any F[A] into cats.free.Coyoneda[F, A]. The question is how to get F[A] out.

If F is a cats.Functor, then it is totally natural that you can use it's native map, and, in fact, there will not be any difference in result with using Coyoneda and using F directly, due to functor law (x.map(f).map(g) <-> x.map(f andThen g)).

In your case, it's not. But you can tear cats.free.Coyoneda apart and delegate to your own map:

def coy[A](fa: MyDaSt[A]): Coyoneda[MyDaSt, A] = Coyoneda.lift(fa)
def unCoy[A: ClassTag](fa: Coyoneda[MyDaSt, A]): MyDaSt[A] =
  fa.fi.map(fa.k) // fi is initial value, k is the composed function

Which will let you use functions expecting cats.Functor:

def generic[F[_]: Functor, A: Show](fa: F[A]): F[String] = fa.map(_.show)

unCoy(generic(coy(v))) // ok, though cumbersome and needs -Ypartial-unification on scala prior to 2.13

(runnable example on scastie)

An obvious limitation is that you need to have a ClassTag[A] in any spot you want to call unCo - even if you did not need it to create an instance of MyDaSt[A] in the first place.

The less obvious one is that you don't automatically have that guarantee about having no behavioral differences. Whether it's okay or not depends on what your map does - e.g. if it's just allocating some Arrays, it shouldn't cause issues.

Upvotes: 3

Related Questions