Inline constructor parameters

Relate to Inline parameters for constructors.

Introduction

The Dotty inline feature offers many new possibilities without “black magic”, especially for compile-time manipulations/value retrieval. However, this feature is disallowed for constructor parameters.

**Disclaimer: This is not (yet) a SIP. It is a “simple” proposal I’m posting on Scala Contributors to get feedback and to discuss about it.

Motivation

Allowing constructors (and given ... with) to have inline parameters would enable new usages at both reducing overhead and evaluating compile-time values.

Example 1: compile-time typeclasses

Say we have a typeclass method you want to evaluate at compile-time (in an inline condition or using Expr#value in a macro), for example a refined types system

//Represent a refined type
opaque type Refined[A, B] <: A = A

//Our inline typeclass
trait Constraint[A, B]:

  inline def test(value: A): Boolean

//Refine a value at compile-time
inline def assertValue[A, B](inline value: A)(using inline constraint: Constraint[A, B]): Refined[A, B] =
  inline if constraint.test(value) then value
  else compiletime.error("Does not satisfy the constraint")

This works for typeclass instances that doesn’t require inline params:

final class Positive

given Constraint[Int, Positive] with

  override inline def test(value: Int): Boolean = value > 0

assertValue[Int, Positive](1) // 1: Refined[Int, Positive]
assertValue[Int, Positive](-1) // Compile-time error: "Does not satsify the constraint"

However, this doesn’t work when (non inline because disallowed) parameters are called:

final class Not[B]
given [A, B](using constraint: Constraint[A, B]): Constraint[A, Not[B]] with
  
  override inline def test(value: A): Boolean = !constraint.test(value)

assertValue[Int, Not[Positive]](-1) //Compile-time error, expected a known value

because constraint.test(value) is not fully inline. As a consequence, the final condition !constraint.test(value) isn’t either.

Allowing inline parameters would fix this limitation:

final class Not[B]
given [A, B](using inline constraint: Constraint[A, B]): Constraint[A, Not[B]] with
  
  override inline def test(value: A): Boolean = !constraint.test(value) //Get fully inlined (if value is inlinable too)

assertValue[Int, Not[Positive]](1) //Compile-time error: Does not satisfy the constraint
assertValue[Int, Not[Positive]](-1) //-1: Refined[Int, Not[Positive]]

Example 2: reduce monadic code overhead

With this change, we could eliminate overhead of some monad transformers:

//Anloguous to `A => B` but inline. This is already doable with the current Scala version
trait InlineFunction[A, B]:

  inline def apply(a: A): B


class Monad[T](inline val value: => T):

  inline def flatMap[A](inline f: InlineFunction[A, Monad[B]]): Monad[A] = f(value)

  inline def map[A](inline f: InlineFunction[A, B]): Monad[A] = Monad(f(value))

then

Monad(5)
  .map(_ * 2)
  .map(_ / 10)
  .value

is inlined to 5*2/10 which is also inlined to 1 (See Inline parameters)

Design

Here are some rules:

  • Like method’s inline parameters, constructor’s inline parameters are immutable.
  • Constructors cannot be inline. The goal of this proposal is to allow classes/constructors to carry inline parameters. An inline constructor is non sense (atleast to me).
  • given ... with syntax is also concerned by this change.

Here is a question asked in the old thread:

This should behave the same as classic inline instance methods, which already exist in the current version of Scala: since dummy is not inline, it doesn’t desugar to Dummy(value) so dummy("Hello World").value will not be inlined.

For instance in current versions:

class Dummy:

  inline def value: String = "hey"

def dummy: Dummy = Dummy()

dummy.value // -> dummy.value
class Dummy:

  inline def value: String = "hey"

def dummy: Dummy = Dummy()

println(compiletime.codeOf(dummy.value)) // -> "hey"

Compatibility

This change shouldn’t break compatibility for existing features. However, note you cannot use a compiled/TASTY code that uses inline parameters in a previous version which doesn’t support them simply because it is not a “simple syntactic sugar”.

See also

4 Likes

I’m in support of this proposal, as I was planning to write a very similar one !

In case you’re working with sufficiently simple types, a workaround is to do the following transformation:

type E = ???
class Wrap(inline val e: E)
// =>
class Wrap[T <: E & Singleton](val e: T)

// But not the following !
class Wrap(val e: E & Singleton)

This works at least for E=Int, I didn’t try the rest
For (a lot) more examples of what works and doesn’t work, see this:

Commented lines do not compile, some of which are somewhat expected, some others are quite surprising

Since it is less convenient to work at the type level*, you can create an object which has the same methods as your class, but with an extra argument:

As we can see, it might also be useful to allow inline if and similar in class/object/trait bodies once we have inline constructor parameters

Some context behind this code snippet To try and prove the turing completeness of Toi (brainf*** with set operations instead of tape operations), I'm trying to write a Turing Machine inside of it.

I wrote a small interpreter using scala parser combinators, and a big library of functions on common types like ints, bools, tuples and lists, and all of that inline, to really drive across this is all simple syntactic sugar
The goal with tuples was to be able to write object Triple extends Tuple(3)
But I couldn’t; as the arity parameter n: Int is not inline, any called function would fail at compiletime if called. After some trial and error, I found the workarounds above

(Links don’t work in collapsible sections: Toi)

*Example of types being cumbersome:

type N <: Int & Singleton
val n: N = valueOf[N]

val sn: S[N] = n+1  //Found: Int Required: S[N]
2 Likes

Thank for sharing!
This is the current workaround I use in one of my projects. Taking the Not example again:

//Using Constraint[A, B] instead of C will cause a "deferred inline error"
class NotConstraint[A, B, C <: Constraint[A, B]](using C) extends Constraint[A, Not[B]]:

  override inline def test(value: A): Boolean = !summonInline[C].test(value)

inline given [A, B, C <: Constraint[A, B]](using inline constraint: C): Constraint[A, Not[B]] = new NotConstraint

assertValue[Int, Not[Positive]](1) //Compile-time error: Does not satisfy the constraint
assertValue[Int, Not[Positive]](-1) //-1: Refined[Int, Not[Positive]]

but this gets very ugly as your typeclass/composition complexifies.

I will try yours to see if it looks better, waiting for the proposal to be explored :slightly_smiling_face:

3 Likes

Wonder if this should be a compiler error instead? So compiler enforces that creation scope isn’t escaped.

2 Likes

I get what you mean. In this example:

case class Dummy(inline value: String)
def dummy(value: String): Dummy = Dummy(value)
dummy("Hello World").value

would be inlined to dummy’s internal value which is actually non-sense.

However, if we make dummy inline:

case class Dummy(inline value: String)
inline def dummy(value: String): Dummy = Dummy(value)

then

dummy("Hello World").value

would be inlined to

val value = "Hello World"
Dummy("Hello World").value

which could be inlined again to

val value = "Hello World"
value

if we also make value (of dummy), then it would inline to "Hello World".

Finally, I think you’re right: compiler should enforce that creation scope isn’t escaped after inlining the preceding call

Re-taking the inline dummy example:

case class Dummy(inline value: String)
inline def dummy(value: String): Dummy = Dummy(value)

what I mean by “preceding call” is this step:

val value = "Hello World"
Dummy("Hello World").value

where the code calling the instanciation got inlined (if marked as inline).

case class Dummy(inline value: String)

def dummy(value: String): Dummy =
  //Creation scope
  Dummy(value)

inline def inlineDummy(value: String): Dummy = Dummy(value)
dummy("Hello World").value //Error: escapes the creation scope
//Creation scope
inlineDummy("Hello World").value

//Desugars to
val value = "Hello World"
//Creation scope
Dummy(value).value

//Desugars to
val value = "Hello World"
value
2 Likes

I’m not sure I understood this “escaping the creation scope”, for me calling a constructor with inline parameters should be exactly like calling a method with an inline parameter

In particular you should be able to do it inside another method

Note also that there is probably cases in which we do not want all parameters to be inline, for example:

class MyClass(val myParam: Int, inline debug: Boolean = false):
  inline if debug then println(myParam)
  def myMethod(val myArg: String) =
    inline if debug then println( "myMethod called with parameter: " ++ myArg)
    // <method body>

(I think this might make more sense as a Trait like MyTrait(inline debug ...)(val myParam: ...) so we can subtype it with debug set)
To make it more clear, maybe we should also have inline classes/traits/objects ?
(They would in particular completely erase during compilation, which is interesting)

PS: Is there a word for classes+traits+objects ?

It actually cannot be behave exactly like a classic inline method.

Taking again the above example:

case class Dummy(inline value: String)
def dummy(value: String): Dummy = Dummy(value)

How can you inline dummy("Hello World").value? You can only do it if the called instance is fully inlined (its parameters don’t need to be inlined through). This restriction is the same as “being in creation scope after inlining” altrough it is probably clearer.

If dummy is not inlined, then the .value would desugar to dummy’s value param which is out of scope.

I totally agree and this is why I am not talking about inline classes but inline parameters. Your example appear totally “valid” taking the initial proposal.

Note this is not about inline classes but inline constructor params. This proposal also applies to traits but how can it be related to objects ?

It seems a bit weird (to me) to allow inline parameters to something that isn’t inline.
inline parameters are only allowed on inline methods, because the whole thing is inlined at compile time. It would make more sense to mark the class as inline too :slight_smile:

3 Likes

I see, in general you can’t indeed, for me this is not the most important part of the proposal, what is important for me is to be able to call inline methods of that class with that parameter inside them, for example:

class Tupler(inline val n: Int):
  inline def get(inline i: Int) = 
    //code containing n and i

In the above example, it would indeed make sense to mark the class inline, but sometimes we want to generate a specialisation at compiletime of a class that will be present at runtime, like in this example:

Ah sorry, as there was no example of this I wasn’t sure how this example was to be handled

1 Like

Example 2: reduce monadic code overhead

This example is easy to encode if you remove the classes and use extension methods and abstract types (or maybe opaque types).

import scala.quoted.*

type Monad[T]

object Monad:
  def apply[T](value: T): Monad[T] = value.asInstanceOf[Monad[T]]

  extension [T](inline monad: Monad[T])
    inline def value: T = ${ valueExpr('monad) }

    inline def flatMap[U](inline f: T => Monad[U]): Monad[U] = ${ flatMapExpr('monad, 'f) }

    inline def map[U](inline f: T => U): Monad[U] = ${ mapExpr('monad, 'f) }

  private def valueExpr[T: Type](monad: Expr[Monad[T]])(using Quotes): Expr[T] =
    extractValueExpr(monad)
      .getOrElse('{ $monad.asInstanceOf[T] })

  private def flatMapExpr[T: Type, U: Type](monad: Expr[Monad[T]], f: Expr[T => Monad[U]])(using Quotes): Expr[Monad[U]] =
    extractValueExpr(monad)
      .map(value => Expr.betaReduce('{ $f($value) }))
      .getOrElse('{ $f($monad.value) })

  private def mapExpr[T: Type, U: Type](monad: Expr[Monad[T]], f: Expr[T => U])(using Quotes): Expr[Monad[U]] =
    extractValueExpr(monad)
      .map(value => '{ Monad(${Expr.betaReduce('{ $f($value) }) })})
      .getOrElse('{ Monad($f($monad.value)) })

  private def extractValueExpr[T: Type](monad: Expr[Monad[T]])(using Quotes): Option[Expr[T]] =
    monad match
      case '{ Monad[T]($value) } => Some(value)
      case _ => None
def test: Int =
  Monad(5)
    .flatMap((x: Int) => Monad(x * 2))
    .map(_ * 2)
    .flatMap((x: Int) => Monad(x * 3))
    .map(_ / 10)
    .value
// def test: Int = 6
1 Like

It seems this one has the same solution as example 2.

import scala.quoted.*

//Represent a refined type
type Refined[A, B] = Refined.Opaque[A, B]
object Refined:
  opaque type Opaque[A, B] <: A = A


//Refine a value at compile-time
inline def assertValue[A, B](inline value: A)(using inline constraint: Constraint[A, B]): Refined[A, B] =
  inline if constraint.test(value) then value.asInstanceOf[Refined[A, B]]
  else compiletime.error("Does not satisfy the constraint")


//Our inline typeclass
type Constraint[A, B] = Constraint.Opaque[A, B]
object Constraint:
  opaque type Opaque[A, B] = A => Boolean
  def apply[A, B](test: A => Boolean): Constraint[A, B] = test

inline given Constraint[Int, Positive] = Constraint(_ > 0)

extension [A, B](inline constraint: Constraint[A, B])
  inline def test(value: A): Boolean = ${ testExpr('constraint, 'value) }

private def testExpr[A: Type, B: Type](constraint: Expr[Constraint[A, B]], value: Expr[A])(using Quotes): Expr[Boolean] =
  constraint match
    case '{ Constraint[A, B]($test) } =>
      Expr.betaReduce('{ $test($value) })
    case _ =>
      quotes.reflect.report.errorAndAbort("Constraint not known statically\n" + constraint.show, constraint)

final class Positive

final class Not[B]

inline given [A, B](using inline constraint: Constraint[A, B]): Constraint[A, Not[B]] =
  ${ notConstraintExpr('constraint) }

private def notConstraintExpr[A: Type, B: Type](constraint: Expr[Constraint[A, B]])(using Quotes): Expr[Constraint[A, Not[B]]] =
  '{ Constraint(x => !${testExpr(constraint, 'x)}) }
def test1: Refined[Int, Positive] =
  assertValue[Int, Positive](1)

def test2: Refined[Int, Positive] =
  assertValue[Int, Positive](-1) // error: Does not satisfy the constraint

def test3: Refined[Int, Not[Positive]] =
  assertValue[Int, Not[Positive]](-1)

def test4: Refined[Int, Not[Positive]] =
  assertValue[Int, Not[Positive]](1) // error: Does not satisfy the constraint
3 Likes

I also found another workaround but both feel (atleast to me) not really natural and they don’t look like “normal” typeclasses.