此文档页面特定于 Scala 3,可能涵盖 Scala 2 中不可用的新概念。除非另有说明,此页面中的所有代码示例均假定您使用的是 Scala 3。
内联是一种常见的编译时元编程技术,通常用于实现性能优化。正如我们将看到的,在 Scala 3 中,内联的概念为我们提供了使用宏进行编程的入口点。
- 它将内联引入为 软关键字。
- 它保证内联实际上会发生,而不是尽力而为。
- 它引入了保证在编译时求值的运算。
内联常量
内联的最简单形式是在程序中内联常量
inline val pi = 3.141592653589793
inline val pie = "🥧"
在上面的内联值定义中使用关键字 inline
保证对 pi
和 pie
的所有引用都已内联
val pi2 = pi + pi // val pi2 = 6.283185307179586
val pie2 = pie + pie // val pie2 = "🥧🥧"
在上面的代码中,引用 pi
和 pie
已内联。然后,编译器应用称为“常量折叠”的优化,该优化在编译时计算结果值 pi2
和 pie2
。
内联 (Scala 3) 与 final (Scala 2)
在 Scala 2 中,我们会在没有返回类型的定义中使用修饰符
final
final val pi = 3.141592653589793 final val pie = "🥧"
修饰符
final
将确保pi
和pie
采用文字类型。然后,编译器中的常量传播优化可以对这些定义执行内联。但是,这种形式的常量传播是尽力而为,无法保证。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
使用三种不同类型的参数,说明内联以不同的方式处理这些参数
-
按值参数。编译器为按值参数生成
val
绑定。这样,在方法主体被缩减之前,参数表达式只被计算一次。这可以在示例中的参数
level
中看到。在某些情况下,当参数是纯常量值时,将省略绑定并直接内联该值。 -
按名称参数。编译器为按名称参数生成
def
绑定。这样,每次使用参数表达式时都会对其进行计算,但会共享代码。这可以在示例中的参数
message
中看到。 -
内联参数。内联参数不会创建绑定,只是简单地内联。这样,它们的代码会在它们被使用的任何地方重复。
这可以在示例中的参数
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)
即使现在我们知道 x
是 String
,调用 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
的条件是已知常量(true
或false
),可能是在内联和常量折叠之后,则条件将被部分求值,并且只保留一个分支。
例如,以下幂方法包含一些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
...
为了确保分支确实可以在编译时执行,我们可以使用if
的inline 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
的代码必须在运行时存在。我们称之为保留内联方法。
对于任何非保留内联 def
或 val
,代码始终可以在所有调用点完全内联。因此,这些方法在运行时不需要,并且可以从字节码中擦除。但是,保留内联方法必须与未内联的情况兼容。特别是,保留内联方法不能采用任何内联参数。此外,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] = ...