When do inline defs evaluate their parent?

Compare the following two examples:

class Foo(y: Int):
  inline def hello = "hello"

lazy val x = 
  println("x")
  Foo(1)

x.hello // returns "hello" without printing "x"

{println("x"); Foo(1)}.hello // returns "hello" AND prints "x"

This seems inconsistent, is this a bug ?

1 Like

This is just a guess, but maybe because inline is resolved at compile time, you’re not going to see runtime effects like printing in the first case?

In general, a lot of care is taken so that side-effects still happen and in the order we would expect (otherwise we wouldn’t need inline parameters to inline functions, they would just get inlined all the time)

And I’m not sure what I would expect for these cases, but at the very least for them to match !

2 Likes

That looks consistent to me. Braces are eager; lazy val is not.

An inline def on a class doesn’t touch the class if it doesn’t actually use it at all; it’s just using the type. For instance:

scala> class Foo(y: Int):
     |   inline def hello = "hello"
     | 
// defined class Foo
                                                                                
scala> (scala.compiletime.erasedValue[Foo]).hello
val res0: String = hello

So, in the second case you have, if you unwrap what it means,

val temp = { println("x"); Foo(1) }
temp.hello

which obviously creates a Foo. It wasn’t used, but you asked for it.

Oh I see, thanks !

This looks like a very nasty gotcha. I thought Scala 3 worked towards eliminating them, not introducing more.

1.
This current behavior is counter-intuitive and it is going to bite someone in a really bad way in the future. The intuition of using lazy vals is that they are fully executed as soon as you reference them somewhere. See this snippet:

class Foo(y: Int):
  inline def hello = "hello"

lazy val x = 
  println("x")
  Foo(1)

x.hello // doesn't print "x"
x       // prints "x" by just referencing

How does this make sense?

2.
Making a method inline should never change the semantics of the program! In this documentation page, it says parameters were specifically designed with the sole purpose of inlining not having semantical differences.

https://docs.scala-lang.org/scala3/guides/macros/inline.html

5 Likes

It doesn’t change the semantics, only the side-effects surrounding initialization. “But I mean for initialization side-effects to be semantics!” you might say. Well…maybe?

I agree that it’s a bit surprising. But on the other hand, suppose you have

object Bar:
  println("Bar!")
  inline def goodbye = "goodbye"

Suppose you call Bar.goodbye. Do you really expect Bar’s initializer to run? If yes, how the heck do you actually just inline pure code? All inline code is in something. “To use inline code, you must create objects” seems completely opposite to the intent of inline code.

What about this one:

scala> lazy val y =
     |   println("Hey")
     |   47
     | 
lazy val y: Int
                                                                                
scala> inline def ignore[A](a: A): Unit =
     |   println("I totally ignored my input")
     | 
def ignore[A](a: A): Unit
                                                                                
scala> ignore(y)
I totally ignored my input

That seems sensible enough! You didn’t use y at all, so why would you initialize it?

And if you “have” something of the right type, but it happens to be null, but you don’t use it, who cares? And indeed, who cares:

scala> val a = (null: Foo).hello
val a: String = hello

The question really is whether one views initialization as incidental to performing the operation if the operation does not depend on it (in which case it can be elided as a performance optimization), or whether one views initialization as part of the “same operations” as required by “same semantics”.

Personally, I think the current behavior is better. People will be surprised either way, because at different times they’ll think, “But of course I care about initialization!” or, “But of course initialization is an irrelevant detail to skip if possible!”

In terms of writing efficient, flexible code (without which one wouldn’t bother with inline at all), the current behavior is, I think, superior.

1 Like

That ignore example seems a bit surprising to me… I would expect a to always be evaluated.

If I didn’t want that, I would use one of the following:

inline def ignore1[A](a: => A): Unit =
  println("I totally ignored my input")

inline def ignore2[A](inline a: A): Unit =
  println("I totally ignored my input")

inline def ignore3[A](inline a: => A): Unit =
  println("I totally ignored my input")

Which actually ignore the input in all cases (e.g. ignore(println("Foo")) will print “Foo”, which is not the case for any of those variants).

1 Like

I see the point, but again, I think it depends on the extent to which you view initialization as incidental or intentional.

I guess the clearest evidence of some lack of conceptual clarity is the following:

scala> val it = Array(1, 2, 3, 4).iterator
val it: Iterator[Int] = <iterator>
                                                                                
scala> def byname(i: => Int): Unit =
     |   ignore(i)
     |   ignore(i)
     | 
def byname(i: => Int): Unit
                                                                                
scala> byname(it.next)
I totally ignored my input
I totally ignored my input
                                                                                
scala> while it.hasNext do println(it.next)
3
4

This argues the opposite from the lazy val case. It’s not quite the same, because => A does not promise any kind of stability for the return values, so the contract is that whenever you need an A you run some code. In contrast, lazy val promises that you will get the unique stable A when you actually need it but not before.

But this is a pretty fine hair to split, especially since in practice people often use a: => A to mean lazy a: A. (We really should have both.)

What I really don’t like is breaking the fundamental assumption of programmers that a().b() does everything that a() does, plus something extra on top: .b()

class Foo(y: Int):
  inline def hello = "hello"

lazy val printer = 
  println("x")
  Foo(1)

printer.hello // doesn't print "x" ?
printer       // prints "x"

This breaks the substitution principle there these two snippets of code are not equivalent:

// prints "x"
val tmp = printer
tmp.hello

// doesn't print "x"
printer.hello
1 Like

One thing worth bringing up is that even in Scala 2.x, a().b() doesn’t always evaluate a():

@ implicit class Foo[T](t: => T){ def bar = 123 } 
defined class Foo

@ println("hello").bar  // doesn't print hello
res1: Int = 123

Whether or not it’s a bad thing I don’t have a strong opinion, and I haven’t thought deeply about the semantics of inline defs to argue for or against their current behavior. But at least regarding the “thing on the left must always evaluate” assumption, it seems like that ship sailed a long time ago

2 Likes

Not wanting to be that “pedantic” guy.
But, just for the record, the presence of side-effects means the substitution principle won’t hold all the time anyways.

So, you either truly care about that and track your effects in some way or another. Or you do not, and accept that you need to analyze each piece of code carefully to be sure if and when effects happen.

And for better or worse, Scala as a language decided that on its core tracking effects was not its goal.


All that to say, while at first, I was also confused about the behavior. And I also would prefer more explicitness in some of the examples as @JD557 showed.
It seems the semantics here are correct.

I simply would not code in a way that depends on hidden evaluation of effects anyways.

Right, I was also a bit confused by this behavior as I know that for instance the optimizer in the compiler backend goes out of its way to preserve evaluation order and possible side effects while inlining. But in the case of inline defs the inlining is an explicit feature of the language that is visible in the source code, so I guess it’s fine and possibly even desirable that inline code can behave differenly than non-inline code.

Though I have to say I saw some example passing by in this thread that I still find pretty confusing at first glance.

1 Like