Inline parameters for constructors

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

I’m not really sure if extensions methods can be defined inline

They can

Another example could be something like this:

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

I don’t really see the use case of a such method.

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).

Ok so there is currently no issue with the above summary ? Great :smile:

I grabbed it because it actually exists in Cats, and it came to mind because the example was a Monad, and it was a method I knew there was a pre-existing usecase for.

Yep, at least as far as I can tell, it’s currently internally consistent and unsurprising as-is.

As an understanding check, is this how you understand the stepwise inlining would go for the Range example?

Class definition

This is identical to the summary above, I’m copying it here for reference

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
  }
}

Base

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

Stage 1

I’m going to handwave the compiler generating a unique identifier for i, to, from, etc

val seq = {
  val r = Range(1, 100)
  r.inlinedMap(i => i * 2)
}

Stage 2

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

Stage 3

This caches the inline parameters, to avoid multiple execution, which I think is consistent with the inline constructor parameters being by-value vals, rather than being by-name.

The implications of this is that, if we want to be able to have by-name semantics, the user would have to supply a wrapper and have the actual inline parameter be a function value.

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

I grabbed it because it actually exists in Cats, and it came to mind because the example was a Monad , and it was a method I knew there was a pre-existing usecase for.

Interesting. I will check that

This is identical to the summary above, I’m copying it here for reference
code…

That’s exactly what I mean.

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

Here, r.from and r.to are inline. I think you wrote them because we don’t have any identifier/unknown for them.

I’m not sure about the correctness of my sentence so I’ll make an example:

If I do Range(a, b).map(_ * 2), r.from = a and r.to = b. I will get:

val seq = {
  val to = a //r.from is inlined to b
  val from = b //r.to is inlined to a
  var i = from
  val buffer: mutable.Buffer[T] = mutable.Buffer()
  while(i < to) {
    buffer += i * 2
    i += 1
  }
  buffer
}

This caches the inline parameters, to avoid multiple execution, which I think is consistent with the inline constructor parameters being by-value val s, rather than being by-name.

Is this cache system already implemented for inline definition or is it a proposal ?

EDIT: Rectified by the author. (See the post below)

1 Like

Yep, that’s a typo on my part, corrected.

It’s a guess, based on how I understood the semantics of the proposal. I’d have to test to see how the existing inlining works. If that’s not how it should work, or not how it works today, it’s probably worth clarifying never mind, I took another pass through the docs, and inline parameters have by-name semantics, I’ll correct my code snippet. Might be worth dropping the requirement that parameters be vals to avoid confusion.