Inline parameters for constructors

Actually in Scala 3, this code does not compile:

trait Constraint {
  inline def assert(value: Foo): Boolean
}

class AndConstraint(inline left: Constraint, inline right: Constraint) extends Constraint {

 override inline def assert(value: Foo): Boolean = left.assert(value) && right.assert(value)
}

This restriction can lead to some issues, for example in metaprogramming when we use FromExpr to retrieve a value at compile-time.


Shouldn’t Dotty add support of inline for constructor parameters ?

Example

Here is the suggested behaviour:

  • an inline parameter can’t be var
  • the constructor isn’t inline itself (what would be an inline constructor ?)
  • the generated getter is inline and is should behave like any other inline method:
scala.compileTime.constValue

trait Constraint {
  inline def assert(value: Int): Boolean
}

class Positive extends Constraint {
  override inline def assert(value: Int): Boolean = value > 0
}

class Lesser[V <: Int] extends Constraint {
  override inline def assert(value: Int): Boolean = value < constValue[V]
}

//Each call of left or right will be replaced by the inlined value
class AndConstraint(inline left: Constraint, inline right: Constraint) extends Constraint {

  override inline def assert(value: Int): Boolean = left.assert(value) && right.assert(value)
}

@main def test = {
  inline val andConstraint: AndConstraint = AndConstraint(Positive, Lesser[3])
  andConstraint.assert(8) //replaced by `false`
  andConstraint.assert(2) //replaced by `true`
}
1 Like

How exactly would this work? Would the entire object somehow be inlined, without an actual instance of AndConstraint being created at runtime?

1 Like

The object in itself shouldn’t be inlined. This should work as instance inline methods already do.

inline val andConstraint: AndConstraint = AndConstraint(Positive, Lesser[3])
andConstraint.assert(8) //Inlined to `false`
andConstraint.left //Inlined to the Constraint instance

Inline variables/methods are already possibles. The actual proposal is to add the possibility to pass inline values to class/trait trough its constructor.

A use case could be the generation of some sort of inline functional interface (like the above Constraint) because Dotty doesn’t allow anonymous inline instance directly due to nested inline methods:

inline val LesserConstraint[V <: Int] = new Constraint {
  override inline def assert(value: Int): Boolean = value < constValue[V]
}

//But this actually works
class Lesser[V <: Int] extends Constraint {
  override inline def assert(value: Int): Boolean = value < constValue[V]
}

inline val LesserConstraint[V <: Int] = new Lesser

For this specific use case, a solution could be the authorisation to do the inline override directly in an anonymous class, even when the class is directly declared inside an inline method. We can already work around this so the compiler should be able to do that (see the above example).

This sounds rather interesting. Could it also prevent creation of unnecessary objects in code such as 1.to(100).map(...) (would the range not be created each time?)?

2 Likes

I see your point but it actually can’t do that by itself because it requires compile-time execution (like in a macro) but it can simplify the process~ forgot to remove this part when writing my post. Sure it does ! (see below)

Default FromExprs can retrieve the value of an inline variable or a chain of inline. Adding the inline parameter concept allow us to retrieve the value at compile-time, even trough classes. Then, we can compute it.

In your example, you can reproduce that with inline constructor parameters:

import scala.quoted.*

class Range(inline from: Int, inline to: Int) {
  
  inline def inlinedMap[T](function: InlinedFunction[Int, T]): Seq[T] = {
    var i = from
    val buffer: mutable.Buffer[T] = mutable.Buffer()
    while(i < to) {
      buffer += function(i)
      i += 1
    }
    buffer
  }
}

This code:

val seq = Range(1, 100).inlinedMap(_ * 2)

Will desugar into

val seq = {
  var i = 0
  val buffer: mutable.Buffer[T] = mutable.Buffer()
  while(i < 100) {
    buffer += i*2
    i += 1
  }
  buffer
}

Only the final collection is created.

2 Likes

Out of curiosity, wonder if this also makes writing perf efficient monad transformers code?

1 Like

I have to admin I didn’t work that much with monads. It looks interesting. I will dig into this but shouldn’t this simply work the same way as the inlinedMap I sent above ?

EDIT: Do you mean avoid the re-composition of the final function when chaining ?

1 Like

I didn’t receive any reply so I will assume I’m right and you asked about function re-composition.

Firslty, let’s make a summary.

Introduction

The new inline keyword actually offers many possibilities:

  • Compile-time optimizations
  • Pre-computed return types (transparent inline)
  • Better way to retrieve value at compile-time using a macro (FromExpr)

Actually this only applies on methods and vals. Here I propose to add the possibility to inline constructor parameters.

Motivation

We can’t actually fully inline values passed through a class. While it’s generally not necessary to fully inline a value through a class, it is required in some cases.

Let’s take the @ysthakur’s use case:

This is possible using inline parameters:

import scala.quoted.*

class Range(inline from: Int, inline to: Int) {
  
  inline def map[T](function: InlinedFunction[Int, T]): Seq[T] = {
    var i = from
    val buffer: mutable.Buffer[T] = mutable.Buffer()
    while(i < to) {
      buffer += function(i)
      i += 1
    }
    buffer
  }
}

Then, val seq = Range(1, 100).inlinedMap(_ * 2) will desugar into

val seq = {
  var i = 0
  val buffer: mutable.Buffer[T] = mutable.Buffer()
  while(i < 100) {
    buffer += i*2
    i += 1
  }
  buffer
}

There is no extra Range created at runtime. Here the class is used as a receptacle/an inlined step in the final collection building.

@Swoorup pointed another use case for this feature:

Function-composition can be handled by inline methods.

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

  def flatMap[A](f: (=> T) => Monad[A]): Monad[A] = f(value)

  def map[A](f: (=> T) => A): Monad[A] = Monad(f(value))

}

With this implementation, a new Monad will be created at each call sequentially this can lead to performances issues, for example if we use monadic collections. With inline parameters, we’re able to precompute the entire chain into one, composed, function.

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

  inline def flatMap[A](inline f: (=> T) => Monad[A]): Monad[A] = f(value)

  inline def map[A](inline f: (=> T) => A): Monad[A] = Monad(f(value))

}

The following example:

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

will desugar into 5*2/10

Design

Requirements

Like method parameters and vals, inline constructor parameters have to be immutable.

The constructor doesn’t have to be inline itself (what would be an inline constructor ?)

General behaviour

Inline parameters will behave exactly like inline vals:

case class Dummy(inline value: String)

Dummy("Hello World").value //Inlined to "Hello World"

Rules for overriding

If an inline parameter overrides another, non-inline parameter, the inline parameter can also be invoked at runtime.

Due to the inline nature, inline parameters are effectively final.

3 Likes

Would be really awesome and zero-cost runtime functional abstraction if this is possible.

What would happen in

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

dummy("Hello World").value

That’s an interesting point. Maybe we should add a clause to prevent this inconsistency:

  • Classes with inline parameters should be marked as inline
  • Inline classes’s field should only be called directly like Dummy("Hello World").value or through inline methods/vals:
//Same as "Hello World"
inline val dummy: Dummy = Dummy("Hello World")
dummy.value

What would be inline classes ?

In the above examples, inline classes are used as “build-stage” in a monad/collection building.

With the above restrictions, inline classes are “stage-classes” and help to avoid unnecessary object creation.

Is there another inconsistency ? If not, I will push this into my precedent post.

I’m not sure that’s actually inconsistent.

The method is declared as returning Dummy, so the behavior that it actually does so is entirely unsurprising. Yeah, you don’t get the benefit of completely removing the Dummy instantiation, but as that’s exactly what the code says it does, that might not be a problem.

I don’t see how should

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

dummy("Hello World").value

be desugared into.

To clarify, I’m not sure it should be desugared in all cases.

Another way of stating this would be that I don’t think it would be inconsistent to give callers the type they ask for, and that these existing together are both internally internally consistent and predictable:

Original

case class Dummy(inline value: String)

def materialized(v: String): Dummy = Dummy(v)
def partiallyInline1(v: String): String = Dummy(v).value
inline def partiallyInline2(v: String): Dummy = Dummy(v)
inline def completelyInline(v: String): String = Dummy(v).value

val result1 = materialized("Hello World").value
val result2 = partiallyInline1("Hello World")
val result3 = partiallyInline2("Hello World")
val result4 = completelyInline("Hello World")

Desugared

case class Dummy(inline value: String)

def materialized(v: String): Dummy = Dummy(v)
def partiallyInline1(v: String): String = v
inline def partiallyInline2(v: String): Dummy = Dummy(v)
inline def completelyInline(v: String): String = v

val result1: String = materialized("Hello World").value
val result2: String = partiallyInline1("Hello World")
val result3: Dummy = Dummy("Hello World")
val result4: String = "Hello World"

Edit: tried to get them side-by-side for better readability, but it wouldn’t scroll horizontally :man_shrugging:

1 Like

So in the first example, the inline nature of Dummy#value is cancelled.

That’s what I thook. Maybe the compiler should generate a warning about this ?

Maybe? The compiler should flag it if they aren’t expecting a Dummy, so maybe only warn if the type wasn’t explicitly annotated (no idea if that’s possible)?

What do you mean by “if they aren’t expecting a Dummy” ? I’m talking about inline cancellation for this case:

Because the expected inline is erased, I think the compiler should warn for this specific case.

Basically, if a method isn’t declared inline, users should reasonably be expected to understand that the method isn’t going to be inlined and it’s going to behave like a standard method. I’m not sure that warning it won’t do that is all that helpful.

On the other hand, if the inlining is part of the implementation, and the type isn’t explicitly annotated, then I could see a bug like this getting written, and the compiler complaining:

def method(v: String) = Dummy(v)

val value: String = method("Hello World")

I’m not sure the compiler can be reasonably expected to keep track of situations like this, and somehow inline everything at result:

// Dummy.scala
case class Dummy(inline value: String)
object Dummy {
  def method(v: String): Dummy = Dummy(v)
}

// Client.scala
val result = Dummy.method("Hello World").value

On the other hand, if you’re building a DSL that leverages this to inline things in all sorts of wonderful ways, and someone wants to do something to extend that DSL in a way that requires passing a Dummy around a bit, silencing that warning every time you call Dummy.value is going to get pretty annoying.

I’m not sure the compiler can be reasonably expected to keep track of situations like this, and somehow inline everything at result

I’m not saying the compiler should inline Dummy.method("Hello World").value. Due to its inline nature, users can think that the above code will be inlined into "Hello World" but here, that’s not the case because method isn’t inline.

I (currently) don’t see a use case for:

// Dummy.scala
case class Dummy(inline value: String)
object Dummy {
  def method(v: String): Dummy = Dummy(v)
}

// Client.scala
val result = Dummy.method("Hello World").value

and that’s why I think the compiler should warn the user about a possibly unexpected behaviour (here, Dummy.method("Hello World").value not being inlined to "Hello World")

It’s probably easier to see with something like this (borrowing the Monad example from above):

class Monad[T](inline val value: => T) {
  inline def flatMap[A](inline f: (=> T) => Monad[A]): Monad[A] = f(value)
  inline def map[A](inline f: (=> T) => A): Monad[A] = Monad(f(value))
}
object Monad {
  def pure[T](v: T): Monad[T] = Monad(v)
}

It would make sense that this should work, particularly if you want to provide some extra assurance that pure has “exactly once” semantics (and the cost of an allocation may well be worth it to gain that guarantee).

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

Because Monad.pure isn’t inline, this could be reasonably be desugared like this:

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

Another example could be something like this:

extension[T](monad: Monad[T]) {
  def as(value: T): Monad[T] = monad.map(_ => value)
}

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

I’m not really sure if extensions methods can be defined inline, but even if they can, it might not be worth bothering with the extra complexity if you’re just writing a quick helper for a test class.

I’d expect this to desugar like this:

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

The extension method itself would have it’s own internal desugaring:

def as(value: T): Monad[T] = monad.map(_ => value)
def as(value: T): Monad[T] = Monad(monad.value; value)
2 Likes