Inline methods and opaque types

When opaque types were announced and saw the official example, I thook about a way to create some sort of “compile time type constraint” without overhead:

import scala.quoted.*
opaque type Positive = Double

object Positive {

  //Same kind of method as described in official example
  def apply(x: Double): Positive = if(x > 0) x else throw new IllegalArgumentException(s"$x must be positive.")

  //"Strong" inline method
  inline def safe(x: Double): Positive = ${safeImpl('x)}

  private def safeImpl(expr: Expr[Double])(using Quotes): Positive = {
    expr.value match {
      case Some(x) if x > 0 => '{$expr}

      case Some(x) if x <= 0 => quotes.reflect.report.error(s"$x must be positive.", expr)

      //Could also be a soft warn like "Unchecked value: Unable to retrieve inlined value"
      case None => quotes.reflect.report.error(s"Unable to retrieve inlined value", expr)
    }
  }
}
def log(x: Positive): Double = ???

log(Positive(1)) //0
log(Positive(0)) // Runtime IllegalArgumentException
log(Positive.safe(0)) //Compile-time error.

Another example of what we can do with “normal” type aliases:

def log(x: Double ==> Positive && NotNull): Double = ???

def cos(x: Double): Double ==> Between[-1, 1] = ???

This is actually possible using non-opaque types or a class but opaque type aliases have an implementation limitation:

Implementation restriction: No inline methods allowed where opaque type aliases are in scope

Making opaque types compatibles with inline methods should make them strongly synergize to create something like the above example. We could be able to create type-level compileTime method/constraints and make our code type-safer without runtime overhead.

10 Likes
4 Likes

This works currently, and gets you quite close.

package positive

import scala.quoted.*

opaque type Positive <: Double = Double
object Positive extends PositiveInline {

  //Same kind of method as described in official example
  def apply(x: Double): Positive = if(x > 0) x else throw new IllegalArgumentException(s"$x must be positive.")

  private[positive] def fromUnsafe(x: Double): Positive = x
}

trait PositiveInline {

  //"Strong" inline method
  inline def safe(x: Double): Positive = PositiveInline.safe(x)
}
object PositiveInline {

  inline def safe(x: Double): Positive = ${PositiveInline.safeImpl('x)}

  private def safeImpl(expr: Expr[Double])(using Quotes): Expr[Positive] = {
    expr.value match {
      case Some(x) if x > 0 => '{Positive.fromUnsafe($expr)}

      case Some(x) if x <= 0 =>
        quotes.reflect.report.error(s"$x must be positive.", expr)
        ???

      //Could also be a soft warn like "Unchecked value: Unable to retrieve inlined value"
      case None =>
        quotes.reflect.report.error(s"Unable to retrieve inlined value", expr)
        ???
    }
  }
}
3 Likes

MaximeKjaer’s approach using pattern matching sounds really interesting but I prefer the use of the existing compile time error/warn because the Error and Warn types seem to be “magic”.

Can’ t we just use an inlined block in opaque types ?

opaque type Positive[A <: Int] = {
  //Inlined statement 
}

Resulting in:

opaque type Positive[A <: Int] = A match {

  case P if P > 0 => Int 

  case N if N <= 0 => quotes.reflect.report.error(s"$N must be positive.")

  case _ => quotes.reflect.report.error(s"Unable to retrieve inlined value")
}

This reduces the boilerplate needed for each refined type compared to the three above examples.

PS: I’m actually on my phone and this post can contain mistakes/typo.

Aren’t inline methods unallowed here ?

The thing that strikes me is this is 2021 and natural and positive numbers are not part of the standard library in Scala, which is supposed to be a strongly typed language. Personally I don’t feel the need for the full power an Idriss style type system, but just to be able to do simple operations on Integers within the type system would hit so many use cases.

1 Like

The forum prevents me to edit my previous post (“URL resource not found”). I don’t know what it occurs so I send the fix here. I now use the scala.compiletime.requireConst to get singleton type’s value:

opaque type Positive[A <: Int] = {
  //Inlined statement 
}

Resulting in:

import scala.compiletime.requireConst
opaque type Positive[A <: Int] = requireConst[A] match {

  case x if x > 0 => Int 

  case _ => quotes.reflect.report.error(s"$N must be positive.")
}

The code I posted compiles fine (if you comment out the line that’s supposed to not compile that is). The only big restriction is that you can’t put inline in the companion object directly. If it’s not directly in the companion, then it’s fine.

3 Likes

Mhh… so you can already simulate some kind of opaque type constraint. Glad to hear.

It’s a lot of boilerplate but it looks like a good workaround for now.

I think I might have found a simpler workaround than the macros or match types mentioned above.

import compiletime.*

object refined:
  opaque type Positive = Int

  object Positive extends PositiveFactory

  trait PositiveFactory:
    inline def apply(inline value: Int): Positive =
      inline if value < 0 then error(codeOf(value) + " is not positive.")
      else value

    transparent inline def safe(value: Int): Positive | IllegalArgumentException =
      if value < 0 then IllegalArgumentException(s"$value is not positive")
      else value: Positive

@main def run: Unit =
  import refined.*
  val eight = Positive(8)
  // val negative = Positive(-1) // This correctly produces a compile error "-1 is not positive."
  // val random = Positive(scala.util.Random.nextInt()) // This correctly produces a compile error about being unable to inline the method call
  val random = Positive.safe(scala.util.Random.nextInt())
  val safeNegative = Positive.safe(-1)
  val safeFive = Positive.safe(5)
  println(eight)
  println(random)
  println(negError)
  println(safeFive)

Running this through the compiler with -Xprint:genBCode yields:

result of opaquetest.scala after genBCode:
package <empty> {
  @scala.annotation.internal.SourceFile("opaquetest.scala") final module class opaquetest$package$ extends Object {
    def <init>(): Unit = 
      {
        super()
        ()
      }
    private def writeReplace(): Object = new scala.runtime.ModuleSerializationProxy(classOf[opaquetest$package$])
    @main() def run(): Unit = 
      {
        val eight: Int = 8:Int
        val random: Object = 
          {
            val value$proxy1: Int = scala.util.Random.nextInt()
            if value$proxy1.<(0) then new IllegalArgumentException("".+(scala.Int.box(value$proxy1)).+(" is not positive"))
               else 
            scala.Int.box(value$proxy1)
          }
        val negError: IllegalArgumentException = new IllegalArgumentException("".+(scala.Int.box(-1)).+(" is not positive"))
        val safeFive: Int = 5
        println(scala.Int.box(eight))
        println(random)
        println(negError)
        println(scala.Int.box(safeFive))
      }
  }
  trait refined$PositiveFactory() extends Object {}
  @scala.annotation.internal.SourceFile("opaquetest.scala") final class run extends Object {
    def <init>(): Unit = 
      {
        super()
        ()
      }
    <static> def main(args: String[]): Unit = 
      try opaquetest$package.run() catch 
        {
          case error @ _:scala.util.CommandLineParser.CommandLineParser$ParseError => 
            scala.util.CommandLineParser.showError(error)
        }
  }
  final module class refined$Positive$ extends Object, refined.refined$PositiveFactory {
    def <init>(): Unit = 
      {
        super()
        ()
      }
    private def writeReplace(): Object = new scala.runtime.ModuleSerializationProxy(classOf[refined.refined$Positive$])
  }
  @scala.annotation.internal.SourceFile("opaquetest.scala") final module class refined$ extends Object {
    def <init>(): Unit = 
      {
        super()
        ()
      }
    private def writeReplace(): Object = new scala.runtime.ModuleSerializationProxy(classOf[refined$])
    final lazy module <static> val Positive: refined.refined$Positive$ = new refined.refined$Positive$()
  }
  final lazy module val refined: refined$ = new refined$()
  final lazy module val opaquetest$package: opaquetest$package$ = new opaquetest$package$()
}

I feel like I must be missing something, or accidentally throwing away or leaking information somehow, but I played around with some relatively simple methods, types, and declarations that expect the opaque type and all but one seem to correctly pass or fail type checking.

I did notice one issue regarding the transparent method I included, which is that i was unable to explicitly declare the type of val random = Positive.safe(Random.nextInt()) to be Positive | lllegalArgumentException. The compiler with -explain seemed like it evaluated only one of the Positive types to LazyRef(Int) as if the opaque declaration was in scope but not the other. Interestingly, you can ascribe types for val negative: Positive | IllegalArgumentException = Positive.safe(-1) and even val eight: Positive | IllegalArgumentException = Positive(8) despite the second being a likely mistake that is technically valid, though you will inadvertently box the Integer both times.

I don’t know enough to tell if this is the issue mentioned in soronpo’s feature request or if it’s some other strangeness around checking type unions and transparent inlined methods. This issue does completely go away if you simply remove the transparent or change the return type to just Positive and throw the exception instead of returning it.

I suspect my snippet would be expected to fail to compile. In fact, if you change my object refined: to package refined:, then it does fail to compile, though you can wrap the opaque type back in another object to get it working again.

I am afraid this looks like a bug in the compiler, where the necessary check was omitted. So that loophole will likely be filled. We should take another look at the problem whether there is something else one can do.

See: Loophole in "no inlined with opaque" checking · Issue #12814 · lampepfl/dotty · GitHub

2 Likes

Also, what prevents inline methods to be used with opaque types ?

Correct me if I’m wrong but I currently don’t find a way to create 0 overhead public wrappers because we have to create a non-inline method for conversion. Example:

opaque type UInt = Int

object UInt {
  //Extension methods ...

  //Pseudo-constructor
  def apply(x: Int): UInt = x //Still present at runtime because it can't be inline
}
val test: UInt = UInt(200)

Desugars to:

val test: Int = UInt(200)

instead of

val test: Int = 200

I think it’s the fact that if it were inlined, the place where you’re calling it wouldn’t understand that UInt is really just Int. It makes sense, but it’s rather unfortunate that the compiler can’t ignore that and inline it anyway.

I think this is far too restrictive. If I have inline methods that have nothing to do with the opaque, but I want them placed inside the companion namespace, you are effectively crippling my ability to do so.

I really appreciate the snippet in that bug, I was struggling to find a case where type checking would fail! Explicit type annotations in the inlined method seem to be able to get that snippet to compile and feels, probably naively, like it could be done mechanically by the compiler. However a little more exploration revealed that extension methods on opaque types also have some trouble in inlined methods. This snippet:

object refined:
  opaque type Positive = Int

  extension (p: Positive)
    def +(q: Int): Positive = q + p

  object Positive extends PositiveFactory

  trait PositiveFactory:
    inline def apply(value: Int): Positive = f(value: Positive) + value // error expected here
    def f(x: Positive): Positive = x

@main def run: Unit =
  import refined.*
  val x = 9
  val nine = Positive.apply(x)

produces this error:

-- Error: opaquetest.scala:11:45 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
11 |    inline def apply(value: Int): Positive = f(value: Positive) + value // error expected here
   |                                             ^^^^^^^^^^^^^^^^^^^^^^^^^^
   |                                             undefined: PositiveFactory_this.f(x:refined.Positive).+ # -1: TermRef(TypeRef(TermRef(ThisType(TypeRef(NoPrefix,module class <empty>)),object refined),type Positive),+) at inlining
1 error found

I’m pretty out of my depth at this point, but I do hope this restriction get re-examined at some point, it would be super exciting to have inlined methods for opaque types, even if they’re limited in some way.

Thanks for digging further! I think in the end we have to face that we cannot do inlining with opaque types. In its scope an opaque type establishes a full extensional equality between the type and its alias. This is stronger than a bidirectional conversion or even a GADT equality. Conversely this means that some program fragments that typecheck in the scope of an opaque alias will stop doing so outside the scope, no matter what amount of patching we try to do.

The only hope would be to somehow eliminate opaque type aliases before we do inlining. I am not sure yet whether that’s possible or not.

1 Like

I did a seemingly-successful little experiment earlier this weekend that involved inlining opaques:

EmailAddress.scala:

import scala.quoted.*

opaque type EmailAddress = String
object EmailAddress extends EmailAddressOps[EmailAddress]:

  given (using s: ToExpr[String]): ToExpr[EmailAddress] = s

  def parse(s: String): Either[String, EmailAddress] =
    if (s contains "@") Right(s)
    else Left("No @ symbol")

EmailAddressOps.scala:

import scala.quoted.*

trait EmailAddressOps[EmailAddressTransparent <: String]:

  inline def apply(inline s: String): EmailAddress =
    ${ EmailAddressOps.applyImpl('s) }

  private val pattern = java.util.regex.Pattern.compile("([^@]*)@([^@]*)")

  extension (value: EmailAddressTransparent)
    inline def localPart: String =
      val matcher = pattern.matcher(value: String)
      matcher.matches
      matcher.group(1)
    inline def domainPart: String =
      val matcher = pattern.matcher(value: String)
      matcher.matches
      matcher.group(2)

object EmailAddressOps {
  def applyImpl(expr: Expr[String])(using Quotes): Expr[EmailAddress] =
    import quotes.reflect.*
    expr.asTerm match
      case Inlined(_, _, Literal(StringConstant(s))) =>
        EmailAddress.parse(s) match
          case Right(email) => Expr(email)
          case Left(err) =>
            report.error(s"Not a valid email address: $err", expr)
            '{???}
      case _ =>
        report.error(s"Not a constant", expr)
        '{???}
}

Test.scala


class Test {
  def getDomain(e: EmailAddress): String = e.domainPart

  def run: Unit =
    val em = EmailAddress("a@b")
    println(getDomain(em))
}

Main.scala

@main
def main: Unit =
  (new Test).run

It’s certainly possible that a bugfix would disallow it but this technique seems to work as a way to allow the opaque type’s definition scope to “pass along” the knowledge to see through the opaque to the Ops trait. The only weird part is that you have to explicitly “upcast” / “look through” the opaque type in the inline methods or you get a compiler error.

To be clear, this technique works for the example of known-positive Doubles too.

Positive.scala:

import scala.quoted.*

opaque type Positive = Double
object Positive extends PositiveOps[Positive]:

  given (using d: ToExpr[Double]): ToExpr[Positive] = d

  def fromDouble(d: Double): Option[Positive] =
    if (d > 0) Option(d)
    else Option.empty[Positive]

PositiveOps.scala:


import scala.quoted.*

trait PositiveOps[PositiveTransparent >: Double <: Double]:

  inline def apply(inline d: Double): Positive =
    ${ PositiveOps.applyImpl('d) }

  extension (value: PositiveTransparent)
    inline def +(other: PositiveTransparent): PositiveTransparent =
      ((value: Double) + (other: Double)): PositiveTransparent

object PositiveOps {
  def applyImpl(expr: Expr[Double])(using Quotes): Expr[Positive] =
    import quotes.reflect.*
    expr.asTerm match
      case Inlined(_, _, Literal(DoubleConstant(d))) =>
        Positive.fromDouble(d) match
          case Some(p) => Expr(p)
          case None =>
            report.error(s"Not positive", expr)
            '{???}
      case _ =>
        report.error(s"Not a constant", expr)
        '{???}
}

PositiveTest.scala:

class PositiveTest {
  def run: Unit =
    println(Positive(0.1) + Positive(0.2))
}

Main.scala

@main
def main: Unit =
  (new PositiveTest).run

@smarter pointed out another idea, which looks promising. Plug loophole for "no inlines with opaques" rule by odersky · Pull Request #12815 · lampepfl/dotty · GitHub. I am following up on this.

10 Likes