scalafunctional-programmingscala-cats

How to change monoid for option of endofunctor?


I want to combine two endofunctors in the context of Option. The combining I want is by composing two endofunctors into one via Category.compose. I found that MonoidK[Endo].algebra[*] instance for Semigroup[Endo[*]] satisfies my requirements. I am using Semigroup[Endo[*]] to make Option be monoidal over Endo.

Here is my attempt of implementing it, but it combines two endofunctions not with the Semigroup[Endo[*]] I want to provide, but rather via some cats.kernel semigroup instance for A => B.

import cats.*
import cats.syntax.all.*
import cats.implicits.*

val f: Endo[Int] = _ + 1
val g: Endo[Int] = _ + 2

val of = f.pure[Option]
val og = g.pure[Option]

given Semigroup[Endo[Int]] = MonoidK[Endo].algebra[Int]
val oh = Monoid[Option[Endo[Int]]].combine(of, og)

val res1 = // Some(9)
  oh.mapApply(3)

val res2 = // Some(6)
  Semigroup[Endo[Int]].combine(f, g).apply(3).pure[Option]

res2 is my desired result here. How can I fix it?


Solution

  • Let's add some debugging utilities:

    // subtype of Endo[Int] which would print debug info in evaluation
    case class EndoImpl(name: String, added: Int) extends (Int => Int) {
    
      def apply(a: Int): Int = {
        println(s"$name($a)==$a+$added")
        a + added
      }
    
      override def compose[A](g: A => Int): A => Int = g.andThen(this)
    
      override def andThen[A](g: Int => A): Int => A = (this, g) match {
        case (f1: EndoImpl, g1: EndoImpl) =>
          EndoImpl(s"(${f1.name} andThen ${g1.name})", f1.added + g1.added)
            .asInstanceOf[Int => A]
        case _ => super.andThen(g)
      }
    }
    

    Then let's modify the example a bit

    val f: Endo[Int] = EndoImpl("f", 1)
    
    val g: Endo[Int] = EndoImpl("g", 2)
    
    val of = f.pure[Option]
    val og = g.pure[Option]
    
    given Semigroup[Endo[Int]] = MonoidK[Endo].algebra[Int]
    val oh: Option[Endo[Int]] = Monoid[Option[Endo[Int]]].combine(of, og)
    
    println("oh.mapApply(3)")
    println(
      oh.mapApply(3)
    )
    println()
    
    println("Semigroup[Endo[Int]].combine(f, g).apply(3).pure[Option]")
    println(
      Semigroup[Endo[Int]].combine(f, g).apply(3).pure[Option]
    )
    

    It prints:

    oh.mapApply(3)
    f(3)==3+1
    g(3)==3+2
    Some(9)
    
    Semigroup[Endo[Int]].combine(f, g).apply(3).pure[Option]
    (g andThen f)(3)==3+3
    Some(6)
    

    Interesting:

    Let's try to dig a big more. Both IntelliJ and VC Code+metals have utilities for previewing implicits passed arguments or used for conversions.

    They let us see that the second (expected) implementation is roughly equal to:

    val om: Option[Endo[Int]] = cats.kernel.instances.option.catsKernelStdMonoidForOption[Endo[Int]](
      summon[Semigroup[Endo[Int]]]
    ).combine(of, og)
    

    which works as expected:

    meanwhile the unexpected implementation is:

    catsKernelStdCommutativeMonoidForOption[Endo[Int]](
      catsKernelCommutativeGroupForFunction1[Int, Int]
    ).combine(of, og).mapApply(3)
    

    which is:

    It's a matter of implicit priorities and I would resolve it by manually constructing the expected instance and exposing it as given without priorities doing the guess work.

    For debugging such issues I recommend getting familiar with related features in IDEs. I'd start with opening action/command palette and typing show implicit and looking what pops out there.