1. Introduction

Scala 3 introduced a lot of changes to the core language features. One of the most notable and radical changes is the complete overhaul of metaprogramming.

In this tutorial, we’ll discuss inline, one of the newly introduced metaprogramming features.

2. inline Modifier

Scala 3 introduced the inline modifier to simplify metaprogramming. Most common metaprogramming requirements can be easily implemented using an inline modifier. We can achieve more complex requirements using other low-level metaprogramming concepts like Macros, ReflectAPI, and Run-Time Multi Staging.

3. inline in Scala 2 vs Scala 3

In Scala 2, there is an @inline annotation. However, it’s merely a suggestion, and it doesn’t guarantee the code is inlined.

In Scala 3, the inline modifier guarantees that the code is inlined and will give us a compilation error if it’s not possible to inline the code.

4. inline Usages

There are multiple ways in which we can use the inline keyword. We can apply inline to variables, functions, match statements, if conditions, and so on.

Next, we’ll look at the different ways of using the inline keyword.

4.1. inline def

inline def moves the code from definition-site to call-site. Let’s look at it with an example:

inline def answer(name:String): Unit = println(s"Elementary, my dear $name")
@main def main() = answer("Watson")

Here’s what the Scala compiler converts the above code to:

@main def main() = println(s"Elementary, my dear Watson")

This provides two benefits:

  • avoids a function invocation
  • allows the compiler to apply more optimizations

4.2. inline val

inline val instructs the compiler that the value is a compile-time constant and that the variable can be replaced with the actual value at compile time.

Let’s see it in action:

@main def hello: Unit = 
  if(debugLogEnabled){
    println("Hello world!")
  }
inline val debugLogEnabled = true

During the compilation, the compiler will apply the optimizations in two steps.

In step 1, it replaces the if condition with the inline val value:

@main def hello: Unit = 
  if(true){
    println("Hello world!")
  }

Then, in step 2, the compiler simplifies the if condition, since the value is a constant:

@main def hello: Unit = 
  println("Hello world!")

4.3. Combination of inline val and def

We can use both inline def and val in a single program or function:

inline val debugLogEnabled = true
inline def logTime[T](fn: => T): T = {
  if(debugLogEnabled) {
    val startTime = System.currentTimeMillis
    val execRes = fn
    val endTime = System.currentTimeMillis
    println(s"Took ${endTime-startTime} millis")
    execRes
  } else fn
}

We can now invoke the above method:

@main def hello: Unit = 
  logTime { myLongRunningMethod }

When we compile the above code, the compiler first inlines the def at the call site (main() method, in this case):

@main def hello: Unit = 
  if(debugLogEnabled) {
    val startTime = System.currentTimeMillis
    val execRes = myLongRunningMethod
    val endTime = System.currentTimeMillis
    println(s"Took ${endTime-startTime} nanos")
    execRes
  } else myLongRunningMethod

The compiler then inlines the inline val:

@main def hello: Unit = 
  if(true) {
    val startTime = System.currentTimeMillis
    val execRes = myLongRunningMethod
    val endTime = System.currentTimeMillis
    println(s"Took ${endTime-startTime} nanos")
    execRes
  } else myLongRunningMethod

Since the if the condition is a constant value, the compiler again optimizes the block by removing the else block.

4.4. inline if Condition

We can also use the inline modifier for if conditions as well. If the inline keyword is applied to an if condition, it ensures that at compile-time, either the if or else branch is removed, based on the constant optimization. If the compiler can’t do the simplification, it throws a compilation error.

Let’s see how we can implement it:

inline def hello: Unit = 
  inline if(debugLogEnabled) {
    println("debug is enabled")
  } else { 
    println("debug is disabled")
  }
inline val debugLogEnabled = true

We should note that the inline modifier is applied before the if condition. If we remove the inline modifier from the variable debugLogEnabled, then the compiler won’t inline the variable and, subsequently, it won’t be able to optimize the if block. In this case, the compiler shows a compilation error.

Additionally, we can apply an inline if modifier only for blocks inside an inline method. That means if we remove the inline modifier from the method hello in the above code block, it won’t compile.

4.5. inline Match

Similar to inline if, we can apply the inline modifier to match statements as well:

inline def classify(value: Any) = {
  inline value match {
    case int: Int    => "Int"
    case str: String => "String"
  }
}
@main def main = classify("hello")

The compiler will try to reduce the match to a single branch at compile time. If this isn’t possible, we’ll see a compiler error.

It’s worth noting that an inline match statement can be used only within an inline method.

4.6. inline Parameter

We can also use the inline modifier for method parameters. While optimizing the inline method, the compiler replaces the inline parameter of the method with the value from the call site:

inline def show(inline a: Int) = {
  println("Value of a = " + a)
  println("Again value of a = " + a)
}
@main def main = show(Random.nextInt())

For the above code, the compiler replaces the usage of a with Random.nextInt(). Therefore, if we execute the code, we’ll see different values of a for each print statement.

4.7. Transparent inline

Since inline expansions are done at compile-time, the compiler can type-check the values with even more clarity:

inline def sum(a: Int, b: Int) = a + b
val total = sum(10,20)

For the above code, the compiler calculates the sum of 10 and 20 and assigns the value 30 to the variable total. However, by default, the type of the variable total is Int.

Since we know the value at compile-time, we can provide the type as Literal Type 3o. We can instruct the compiler to use the most specialized type available, by using the keyword transparent:

transparent inline def sum(a: Int, b: Int) = a + b
val total: 30 = sum(10, 20) // The type is Literal Value 30 instead of Int

5. Compile Time Error Generation

Since inline code information is completely available during the compilation stage itself, we can explicitly generate compilation errors if basic requirements aren’t met. As a result, we can catch some of the undesirable situations during the compilation stage instead of at runtime.

Scala 3 allows us to generate these errors using scala.compiletime package. Let’s look at an example where we can generate errors at compile time.

We can add compile time validation for the HTTP port in our app. For instance, we can validate if the port number is between 8080 and 9000:

inline def checkPort(portNo: Int) = {
  inline if (portNo < 8080 || portNo > 9000) {
    scala.compiletime.error("Invalid port number! ")
  } else {
    println(s"Port number $portNo will be used for the app")
  }
}

Now, we can invoke the method checkPort:

checkPort(8080)

This compiles successfully. However, let’s see what happens if we change the code to use another port, for example, 7000:

checkPort(7000)

This fails at compilation time itself with an error message:

[error] | checkPort(7000)
[error] | ^^^^^^^^^^^^^^^
[error] | Invalid port number!

We should note that we can use this approach only if the expression used is a constant expression.

6. Conclusion

In this article, we discussed different ways in which we can use the inline modifier in Scala 3. Inlining allows the Scala compiler to optimize the code more efficiently during the compilation and improve its performance.

As always, the code samples used in this article are available over on GitHub.