correct setup for opaque type with underlying Numeric/Ordering instances

269 Views Asked by At

unclear to me if this is in fact the same question as here or here, apologies if this is a duplicate.

i would like to define a type Ordinate which is simply an Int under-the-hood:

package world 

opaque type Ordinate = Int
given Ordering[Ordinate] with {
  def compare(x: Ordinate, y: Ordinate): Int = x.compare(y)
}

i would like to be able to leverage the Numeric[Int] and Ordering[Int] methods so that it would be easy to define methods such as

package world

import Ordinate.given

class Boundary(dims: List[(Ordinate, Ordinate)]) {
  def contains(o: Ordinate, dimension: Int): Boolean = {
    val (min, max) = dims(dimension)
    min <= o && o <= max
  }
}

...forgetting for the meantime that this would blow up if dims was empty, dimension < 0 or dims.length <= dimension.

when i try and set this up, i get compiler errors at the call site:

value <= is not a member of world.Ordinate, but could be made available as an extension method.

One of the following imports might fix the problem:

  import world.given_Ordering_Ordinate.mkOrderingOps
  import math.Ordering.Implicits.infixOrderingOps
  import math.Ordered.orderingToOrdered

more generally, it would be wicked cool if this were the case without any special given imports for files in the same package as Ordinate and even better, across the codebase. but that may be an anti-pattern that i've carried forward from my Scala 2 coding.

explicit given imports may be a better pattern but i'm still learning Scala 3 from Scala 2 here. i know if i created an implicit val o = Ordering.by(...) in the companion object of Ordinate in Scala 2, with Ordinate as a value class, i would get the effect i'm looking for (zero-cost type abstraction + numeric behaviors).

anyhow, i'm guessing i'm just missing a small detail here, thank you for reading and for any help.

2

There are 2 best solutions below

0
On BEST ANSWER

It is legal to export implicit methods, see https://docs.scala-lang.org/scala3/reference/other-new-features/export.html .

Thus, instead of importing infixOrderingOps at call site, it is possible to export it at definition site. In our case in the companion object of Ordinate.

Definition site:

object world:
    opaque type Ordinate = Int
    given Ordering[Ordinate] with
        def compare(x: Ordinate, y: Ordinate): Int = x.compare(y)
    object Ordinate :
        // Creates implicit infix ordering methods 
        export math.Ordering.Implicits.infixOrderingOps

Call Site

object usage :
    import world.Ordinate
    // vvvvvv ** Not Needed : Ordering[Ordinate] 
    // import world.given
    // vvvvvv ** Not Needed : infixOrderingOps in scope via the export
    // import math.Ordering.Implicits.infixOrderingOps
    def lessThan(x:Ordinate,y:Ordinate):Boolean = x<=y // Works

It is also legal to export extension methods.

object OrderingExtensions:
    extension[T](using ord:Ordering[T]) (x:T)
        def <= ( y:T ) : Boolean =  ord.lteq(x,y)
        // ... and all the others

object world:
    opaque type Ordinate = Int
    given Ordering[Ordinate] with
            def compare(x: Ordinate, y: Ordinate): Int = x.compare(y)
    object Ordinate :
        // vvv Exporting extensions methods
        export OrderingExtensions.*
        

object usage :
    // Required :          vvvvv extension uses Ordering[Ordinate] 
    import world.{Ordinate,given}
    def lessThan(x:Ordinate,y:Ordinate):Boolean = x<=y // Works
0
On

Scala 3 has revised the rules for infix operators so that the author must (explicitly) expose infix operations such as x: T <= y: T for some custom type T.

I've found two ways to address this for an opaque type, both with drawbacks:

  1. at the call site, have import math.Ordering.Implicits.infixOrderingOps in scope, which brings in a given instance that converts Ordering[T] into infix comparators. drawback: any file that wants these comparators needs the import line, adding more import boilerplate as the number of files using this opaque type increases.
package world

import Ordinate.given
import math.Ordering.Implicits.infixOrderingOps  // <-- add this line

class Boundary(dims: List[(Ordinate, Ordinate)]) {
  def contains(o: Ordinate, dimension: Int): Boolean = {
    val (min, max) = dims(dimension)
    min <= o && o <= max
  }
}
  1. add an infix extension method for each comparator you want to expose. drawback here is boilerplate of having to write out the very thing we're trying not to duplicate in each file.
type Ordinate = Int
object Ordinate {
  extension (o: Ordinate) {
    infix def <=(x: Ordinate): Boolean = o <= x  // <-- add 'infix' here
  }
}

i'm guessing for those more experienced with large programs, these drawbacks are better than the drawbacks associated with anything more than this least permission approach to givens. but this still doesn't seem to deliver on the promise of opaque types as a zero-cost abstraction for numeric types. what seems to be missing is something like "import a given and treat it's methods as infix for my type".