Search code examples
scalascala-3

Why does operator associativity work differently for Scala 3 extension methods and regular methods?


When I manually converted Scala 2 code to Scala 3, operator precedence for my DSL changed, and it took me a long time to debug and fix. It seems the handling of : is different:

      extension (i1: Int) def ~>:(i2: Int) = i1 < i2
      extension (i1: Int) def ~>(i2: Int) = i1 < i2

      class Wrap(val i: Int):
        def ~>:(w: Wrap) = i ~>: w.i
        def ~>(w: Wrap) = i ~> w.i

      // `Wrap` preserves `~>`
      println(1 ~> 2) // true
      println(Wrap(1) ~> Wrap(2)) // true

      // `Wrap` does not preserve `~>:`
      println(1 ~>: 2) // true
      println(Wrap(1) ~>: Wrap(2)) // false

My mental model was:

  • For methods ending in :, the reciever is the thing on the right
  • extension methods are just methods: it's as if the method is added to the class

My mental model seems to be wrong. What's the right way of explaining what's happening?

Links would help, I checked the Scala 3 docs and didn't find anything about how custom operators associate.

Update

I tried adding infix keyword before def, but it doesn't change what is printed in this example.


Solution

  • Your mental model just needs some tweaking.

    Recall that the infix x op y de-sugars to x.op(y), except when the op ends with a colon, then it's y.op:(x). This holds true whether the op() method is native to the instance parameter or an added extension, which, in Scala-2, is handled by an intermediate implicit class.

    implicit class IntermediateClass(instance: Int) {
      def op(arg: Int) = ???
    }
    

    A Scala-3 extension, on the other hand, is just a method that receives two curried arguments. So the infix invocation leftOfOp op rightOfOp will always be handled as such:

    extension (leftOfOp: Int)
      def op(rightOfOp: Int) = ???
    

    And it's the same whether op has a trailing : or not. But, while the code at the definition site remains consistent in this manner, the associativity at the call site is as you would expect.

    extension (left: String)
      def @:(right: String):String = s"$left.@:($right)"
      def @@(right: String):String = s"$left.@@($right)"
    
    "TOP" @: "MID" @: "END"  //"TOP.@:(MID.@:(END))"
    "top" @@ "mid" @@ "end"  //"top.@@(mid).@@(end)"
    

    More details can be found here and here.