Scala 3 中的宏

内联

语言
此文档页面特定于 Scala 3,可能涵盖 Scala 2 中不可用的新概念。除非另有说明,此页面中的所有代码示例均假定您使用的是 Scala 3。

内联是一种常见的编译时元编程技术,通常用于实现性能优化。正如我们将看到的,在 Scala 3 中,内联的概念为我们提供了使用宏进行编程的入口点。

  1. 它将内联引入为 软关键字
  2. 它保证内联实际上会发生,而不是尽力而为。
  3. 它引入了保证在编译时求值的运算。

内联常量

内联的最简单形式是在程序中内联常量

inline val pi = 3.141592653589793
inline val pie = "🥧"

在上面的内联值定义中使用关键字 inline 保证pipie 的所有引用都已内联

val pi2 = pi + pi // val pi2 = 6.283185307179586
val pie2 = pie + pie // val pie2 = "🥧🥧"

在上面的代码中,引用 pipie 已内联。然后,编译器应用称为“常量折叠”的优化,该优化在编译时计算结果值 pi2pie2

内联 (Scala 3) 与 final (Scala 2)

在 Scala 2 中,我们会在没有返回类型的定义中使用修饰符 final

final val pi = 3.141592653589793
final val pie = "🥧"

修饰符 final 将确保 pipie 采用文字类型。然后,编译器中的常量传播优化可以对这些定义执行内联。但是,这种形式的常量传播是尽力而为,无法保证。Scala 3.0 还支持 final val 内联作为迁移目的的尽力而为内联。

目前,只有常量表达式可以出现在内联值定义的右侧。因此,以下代码无效,尽管编译器知道右侧是一个编译时常量值

val pi = 3.141592653589793
inline val pi2 = pi + pi // error

请注意,通过定义 inline val pi,可以在编译时计算加法。这解决了上述错误,pi2 将接收文字类型 6.283185307179586d

内联方法

我们还可以使用修饰符 inline 来定义一个应该在调用点内联的方法

inline def logged[T](level: Int, message: => String)(inline op: T): T =
  println(s"[$level]Computing $message")
  val res = op
  println(s"[$level]Result of $message: $res")
  res

当调用 logged 这样的内联方法时,它的主体将在编译时的调用点展开!也就是说,对 logged 的调用将被方法的主体替换。提供的参数被静态地替换为 logged 的参数,相应地。因此,编译器内联以下调用

logged(logLevel, getMessage()) {
  computeSomething()
}

并将其重写为

val level   = logLevel
def message = getMessage()

println(s"[$level]Computing $message")
val res = computeSomething()
println(s"[$level]Result of $message: $res")
res

内联方法的语义

我们的示例方法 logged 使用三种不同类型的参数,说明内联以不同的方式处理这些参数

  1. 按值参数。编译器为按值参数生成 val 绑定。这样,在方法主体被缩减之前,参数表达式只被计算一次。

    这可以在示例中的参数 level 中看到。在某些情况下,当参数是纯常量值时,将省略绑定并直接内联该值。

  2. 按名称参数。编译器为按名称参数生成 def 绑定。这样,每次使用参数表达式时都会对其进行计算,但会共享代码。

    这可以在示例中的参数 message 中看到。

  3. 内联参数。内联参数不会创建绑定,只是简单地内联。这样,它们的代码会在它们被使用的任何地方重复。

    这可以在示例中的参数 op 中看到。

不同参数的翻译方式保证了内联调用不会改变其语义。这意味着在内联时,在键入内联方法的主体时执行的初始详细说明(重载解析、隐式搜索等)不会改变。

例如,考虑以下代码

class Logger:
  def log(x: Any): Unit = println(x)

class RefinedLogger extends Logger:
  override def log(x: Any): Unit = println("Any: " + x)
  def log(x: String): Unit = println("String: " + x)

inline def logged[T](logger: Logger, x: T): Unit =
  logger.log(x)

logger.log(x) 的单独类型检查将解析对方法 Logger.log 的调用,该方法接受类型为 Any 的参数。现在,给定以下代码

logged(new RefinedLogger, "✔️")

它扩展为

val logger = new RefinedLogger
val x = "✔️"
logger.log(x)

即使现在我们知道 xString,调用 logger.log(x) 仍然解析为方法 Logger.log,该方法接受类型为 Any 的参数。请注意,由于延迟绑定,运行时调用的实际方法将是重写的方法 RefinedLogger.log

内联保留语义

无论 logged 被定义为 def 还是 inline def,它执行相同的操作,只是性能上有一些差异。

内联参数

内联的一个重要应用是启用跨方法边界的常量折叠优化。内联参数不会创建绑定,它们的代码会在它们被使用的任何地方重复。

inline def perimeter(inline radius: Double): Double =
  2.0 * pi * radius

在上面的示例中,我们期望如果 radius 是静态已知的,那么整个计算可以在编译时执行。以下调用

perimeter(5.0)

被重写为

2.0 * pi * 5.0

然后内联 pi(我们假设从一开始就有了 inline val 定义)

2.0 * 3.141592653589793 * 5.0

最后,它被常量折叠为

31.4159265359
内联参数应该只使用一次

多次使用内联参数时,我们需要小心。考虑以下代码

inline def printPerimeter(inline radius: Double): Double =
  println(s"Perimeter (r = $radius) = ${perimeter(radius)}")

当常量或对 val 的引用传递给它时,它工作得很好。

printPerimeter(5.0)
// inlined as
println(s"Perimeter (r = ${5.0}) = ${31.4159265359}")

但是,如果传递了一个更大的表达式(可能带有副作用),我们可能会意外地重复工作。

printPerimeter(longComputation())
// inlined as
println(s"Perimeter (r = ${longComputation()}) = ${6.283185307179586 * longComputation()}")

内联参数的一个有用应用是避免创建闭包,这是使用按名称参数产生的。

inline def assert1(cond: Boolean, msg: => String) =
  if !cond then
    throw new Exception(msg)

assert1(x, "error1")
// is inlined as
val cond = x
def msg = "error1"
if !cond then
    throw new Exception(msg)

在上面的示例中,我们可以看到按名称参数的使用导致了局部定义 msg,它在检查条件之前分配了一个闭包。

如果我们使用内联参数,则可以确保在到达处理异常的任何代码之前检查条件。对于断言,永远不应该到达此代码。

inline def assert2(cond: Boolean, inline msg: String) =
  if !cond then
    throw new Exception(msg)

assert2(x, "error2")
// is inlined as
val cond = x
if !cond then
    throw new Exception("error2")

内联条件

如果if的条件是已知常量(truefalse),可能是在内联和常量折叠之后,则条件将被部分求值,并且只保留一个分支。

例如,以下幂方法包含一些if,这些if可能会展开递归并删除所有方法调用。

inline def power(x: Double, inline n: Int): Double =
  if (n == 0) 1.0
  else if (n % 2 == 1) x * power(x, n - 1)
  else power(x * x, n / 2)

使用静态已知常量调用power会产生以下代码

  power(2, 2)
  // first inlines as
  val x = 2
  if (2 == 0) 1.0 // dead branch
  else if (2 % 2 == 1) x * power(x, 2 - 1) // dead branch
  else power(x * x, 2 / 2)
  // partially evaluated to
  val x = 2
  power(x * x, 1)

查看其余内联步骤

// then inlined as
val x = 2
val x2 = x * x
if (1 == 0) 1.0 // dead branch
else if (1 % 2 == 1) x2 * power(x2, 1 - 1)
else power(x2 * x2, 1 / 2) // dead branch
// partially evaluated to
val x = 2
val x2 = x * x
x2 * power(x2, 0)
// then inlined as
val x = 2
val x2 = x * x
x2 * {
  if (0 == 0) 1.0
  else if (0 % 2 == 1) x2 * power(x2, 0 - 1) // dead branch
  else power(x2 * x2, 0 / 2) // dead branch
}
// partially evaluated to
val x = 2
val x2 = x * x
x2 * 1.0

相反,让我们想象一下我们不知道n的值

power(2, unknownNumber)

在参数上的内联注释的驱动下,编译器将尝试展开递归。但由于参数不是静态已知的,因此不会成功。

查看内联步骤

// first inlines as
val x = 2
if (unknownNumber == 0) 1.0
else if (unknownNumber % 2 == 1) x * power(x, unknownNumber - 1)
else power(x * x, unknownNumber / 2)
// then inlined as
val x = 2
if (unknownNumber == 0) 1.0
else if (unknownNumber % 2 == 1) x * {
  if (unknownNumber - 1 == 0) 1.0
  else if ((unknownNumber - 1) % 2 == 1) x2 * power(x2, unknownNumber - 1 - 1)
  else power(x2 * x2, (unknownNumber - 1) / 2)
}
else {
  val x2 = x * x
  if (unknownNumber / 2 == 0) 1.0
  else if ((unknownNumber / 2) % 2 == 1) x2 * power(x2, unknownNumber / 2 - 1)
  else power(x2 * x2, unknownNumber / 2 / 2)
}
// Oops this will never finish compiling
...

为了确保分支确实可以在编译时执行,我们可以使用ifinline if变体。使用inline注释条件将确保条件可以在编译时简化,如果条件不是静态已知常量,则会发出错误。

inline def power(x: Double, inline n: Int): Double =
  inline if (n == 0) 1.0
  else inline if (n % 2 == 1) x * power(x, n - 1)
  else power(x * x, n / 2)
power(2, 2) // Ok
power(2, unknownNumber) // error

我们稍后将回到此示例,并了解如何更好地控制代码的生成方式。

内联方法重写

为了确保将inline def的静态特性与接口和重写的动态特性相结合的正确行为,必须施加一些限制。

实际上是 final

首先,所有内联方法都是实际上是 final的。这确保了编译时的重载解析与运行时的重载解析的行为相同。

签名保留

其次,覆盖必须与被覆盖方法具有完全相同的签名,包括内联参数。这可确保两种方法的调用语义相同。

保留的内联方法

可以使用内联方法实现或覆盖普通方法。

考虑以下示例

trait Logger:
  def log(x: Any): Unit

class PrintLogger extends Logger:
  inline def log(x: Any): Unit = println(x)

但是,直接在 PrintLogger 上调用 log 方法将内联代码,而对其在 Logger 上的调用则不会。为了也允许后者,log 的代码必须在运行时存在。我们称之为保留内联方法。

对于任何非保留内联 defval,代码始终可以在所有调用点完全内联。因此,这些方法在运行时不需要,并且可以从字节码中擦除。但是,保留内联方法必须与未内联的情况兼容。特别是,保留内联方法不能采用任何内联参数。此外,inline if(如 power 示例中所示)将不起作用,因为在保留情况下无法对 if 进行常量折叠。其他示例涉及仅在内联时才有意义的元编程构造。

抽象内联方法

还可以创建抽象内联定义

trait InlineLogger:
  inline def log(inline x: Any): Unit

class PrintLogger extends InlineLogger:
  inline def log(inline x: Any): Unit = println(x)

这强制 log 的实现成为内联方法,并且还允许 inline 参数。违反直觉的是,接口 InlineLogger 上的 log 无法直接调用。方法实现不是静态已知的,因此我们不知道要内联什么。因此,调用抽象内联方法会导致错误。在另一个内联方法中使用时,抽象内联方法的用处变得明显

inline def logged(logger: InlineLogger, x: Any) =
  logger.log(x)

让我们假设对 PrintLogger 的具体实例调用 logged

logged(new PrintLogger, "🥧")
// inlined as
val logger: PrintLogger = new PrintLogger
logger.log(x)

内联后,对 log 的调用被取消虚拟化,并已知在 PrintLogger 上。因此,log 的代码也可以内联。

内联方法的摘要

  • 所有 inline 方法都是最终的。
  • 抽象 inline 方法只能由内联方法实现。
  • 如果内联方法覆盖/实现普通方法,则必须保留它,并且保留的方法不能有内联参数。
  • 抽象 inline 方法不能直接调用(内联代码除外)。

透明内联方法

透明内联是 inline 方法的简单但功能强大的扩展,并且解锁了许多元编程用例。对透明的调用允许内联代码片段根据内联表达式的精确类型来优化返回类型。在 Scala 2 中,透明的捕捉到了白盒宏的本质。

transparent inline def default(inline name: String): Any =
  inline if name == "Int" then 0
  else inline if name == "String" then ""
  else ...
val n0: Int = default("Int")
val s0: String = default("String")

请注意,即使 default 的返回类型是 Any,第一个调用仍被键入为 Int,第二个调用被键入为 String。返回类型表示内联项中类型的上限。我们也可以更精确,可以改写为

transparent inline def default(inline name: String): 0 | "" = ...

虽然在这个示例中,返回类型似乎不是必需的,但当内联方法是递归时,它很重要。在那里,它应该足够精确以用于递归类型,但在内联后会变得更精确。

透明的会影响二进制兼容性

重要的是要注意,更改 transparent inline def 的主体将更改调用站点的类型。这意味着主体在该接口的二进制和源兼容性中起作用。

编译时操作

我们还提供一些在编译时评估的操作。

内联匹配

与内联 if 一样,内联匹配保证模式匹配可以在编译时静态减少,并且只保留一个分支。

在以下示例中,被检查者 x 是一个内联参数,我们可以在编译时对其进行模式匹配。

inline def half(x: Any): Any =
  inline x match
    case x: Int => x / 2
    case x: String => x.substring(0, x.length / 2)

half(6)
// expands to:
// val x = 6
// x / 2

half("hello world")
// expands to:
// val x = "hello world"
// x.substring(0, x.length / 2)

这说明内联匹配提供了一种匹配某些表达式静态类型的方法。由于我们匹配表达式的静态类型,因此以下代码将无法编译。

val n: Any = 3
half(n) // error: n is not statically known to be an Int or a Double

值得注意的是,值 n 未标记为 inline,因此在编译时没有足够的信息来决定采用哪个分支。

scala.compiletime

scala.compiletime 提供了有用的元编程抽象,可在 inline 方法中使用,以提供自定义语义。

内联也是用于编写宏的核心机制。宏提供了一种方法来控制调用内联后的代码生成和分析。

inline def power(x: Double, inline n: Int) =
  ${ powerCode('x, 'n)  }

def powerCode(x: Expr[Double], n: Expr[Int])(using Quotes): Expr[Double] = ...

此页面的贡献者