Inline

Встраивание (inline) — это распространенный метод метапрограммирования во время компиляции, обычно используемый для оптимизации производительности. Концепция встраивания предоставляет собой точку входа в программирование с помощью макросов.

  1. Вводится inline как мягкий модификатор.
  2. Гарантируется, что встраивание происходит на самом деле, а не с "максимальной эффективностью".
  3. Вводятся операции, которые гарантированно оцениваются во время компиляции.

Inline Constants

Простейшей формой встраивания является встраивание констант в программы:

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

Использование ключевого слова inline в определениях значений гарантирует, что все ссылки на pi и pie являются встроенными:

val pi2 = pi + pi
// pi2: Double = 6.283185307179586
val pie2 = pie + pie
// pie2: String = "🥧🥧"

В приведенном выше коде ссылки pi и pie встроены. Затем компилятор применяет оптимизацию под названием "свертывание констант", которая вычисляет результирующее значение pi2 и pie2 во время компиляции.

Inline (Scala 3) vs. final (Scala 2)

В Scala 2 использовался бы модификатор final в определении без возвращаемого типа:

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

Модификатор final обеспечит, что pi и pie примет литеральный тип. Затем оптимизация распространения констант в компиляторе может выполнить встраивание для таких определений. Однако эта форма постоянного распространения не гарантируется. Scala 3.0 также поддерживает final val - inlining как встраивание с максимальной эффективностью для целей миграции.

В настоящее время только константные выражения могут появляться в правой части определения встроенного значения. Таким образом, следующий код недействителен, хотя компилятор знает, что правая часть является постоянным значением времени компиляции:

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

Обратите внимание, что при определении inline val pi добавление может быть вычислено во время компиляции. Это устраняет указанную выше ошибку и pi2 получает литеральный тип 6.283185307179586d.

Inline Methods

Также можно использовать модификатор 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[String](1, "some message")

будет неправильно сформирован, и компилятор будет жаловаться на отсутствие аргументов. Однако можно передавать аргументы с подстановочными знаками. Например,

logged[String](1, "some message")(_)
Семантика встроенных методов

Пример метода 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, "✔")
// Any: ✔

Он расширяется до:

val logger = new RefinedLogger
val x = "✔"
// x: String = "✔"
logger.log(x)
// String: ✔

Несмотря на то, что теперь известно, что 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 определения - radius):

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)
// встраивается как
println(s"Perimeter (r = ${5.0}) = ${31.4159265359}")

Но если передается большее выражение (возможно, с побочными эффектами), можно случайно дублировать работу.

printPerimeter(longComputation())
// встраивается как
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")

В следующем примере показана разница в переводе между by-value, by-name и inline параметрами:

inline def funkyAssertEquals(actual: Double, expected: => Double, inline delta: Double): Unit =
  if (actual - expected).abs > delta then
    throw new AssertionError(s"difference between ${expected} and ${actual} was larger than ${delta}")

funkyAssertEquals(computeActual(), computeExpected(), computeDelta())
// translates to
//
//   val actual = computeActual()
//   def expected = computeExpected()
//   if (actual - expected).abs > computeDelta() then
//     throw new AssertionError(s"difference between ${expected} and ${actual} was larger than ${computeDelta()}")

Обычно встроенные параметры используются, когда необходимо распространить постоянные значения, чтобы обеспечить дальнейшую оптимизацию/сокращение.

Рекурсивные встроенные методы

Встроенные методы могут быть рекурсивными. Например, при вызове с постоянным n следующий метод power будет реализован прямым встроенным кодом без какого-либо цикла или рекурсии.

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

power(expr, 10)
// translates to
//
//   val x = expr
//   val y1 = x * x   // ^2
//   val y2 = y1 * y1 // ^4
//   val y3 = y2 * x  // ^5
//   y3 * y3          // ^10

Встроенные условия

Если условием if является известная константа (true или false), то возможно, что после встраивания и сворачивания констант, условное выражение частично вычислится и сохранится только одна ветвь.

Например, следующий метод power содержит некоторые условия 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)
// первое встраивание
val x = 2
if (2 == 0) 1.0 // мертвая ветка
else if (2 % 2 == 1) x * power(x, 2 - 1) // мертвая ветка
else power(x * x, 2 / 2)
// частично свернулось до
val x = 2
power(x * x, 1)

Дальнейшие шаги встраивания:

// дальнейшее встраивание
val x = 2
val x2 = x * x
if (1 == 0) 1.0 // мертвая ветка
else if (1 % 2 == 1) x2 * power(x2, 1 - 1)
else power(x2 * x2, 1 / 2) // мертвая ветка
// частично свернулось до
val x = 2
val x2 = x * x
x2 * power(x2, 0)
// дальнейшее встраивание
val x = 2
val x2 = x * x
x2 * {
  if (0 == 0) 1.0
  else if (0 % 2 == 1) x * power(x, 0 - 1) // мертвая ветка
  else power(x * x, 0 / 2) // мертвая ветка
}
// частично свернулось до
val x = 2
val x2 = x * x
x2 * 1.0

Напротив, представим, что значение n неизвестно:

power(2, unknownNumber)

Руководствуясь встроенной аннотацией параметра, компилятор попытается развернуть рекурсию. Но безуспешно, так как параметр не известен статически.

// первое встраивание
val x = 2
if (unknownNumber == 0) 1.0
else if (unknownNumber % 2 == 1) x * power(x, unknownNumber - 1)
else power(x * x, unknownNumber / 2)
// дальнейшее встраивание
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)
}
// компиляция никогда не закончится
...

Чтобы гарантировать, что ветвление действительно может быть выполнено во время компиляции, можно использовать inline if вариант 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
val unknownNumber = 2
power(2, unknownNumber) // error  
-- Error: ----------------------------------------------------------------------
|power(2, unknownNumber)
|^^^^^^^^^^^^^^^^^^^^^^^
|Cannot reduce `inline if` because its condition is not a constant value: unknownNumber.==(0)
| This location contains code that was inlined from rs$line$1:2

В прозрачном встроенном объекте inline if принудительно встраивает любое встроенное определение в его условие во время проверки типа.

Переопределение встроенного метода

Чтобы обеспечить правильное поведение при объединении статической функции inline def с динамической функцией интерфейсов и переопределении, необходимо наложить некоторые ограничения.

Эффективно final

Во-первых, все встроенные методы фактически являются final. Это гарантирует, что разрешение перегрузки во время компиляции будет вести себя так же, как во время выполнения.

Сохранение подписи

Во-вторых, переопределения должны иметь точно такую же сигнатуру, как и переопределенный метод, включая встроенные параметры. Это гарантирует, что семантика вызова одинакова для обоих методов.

Сохраненные встроенные методы

Можно реализовать или переопределить обычный метод с помощью встроенного метода.

Рассмотрим следующий пример:

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

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

Однако вызов метода log напрямую у PrintLogger приведет к встроенному коду, а его вызов на Logger — нет. Чтобы также допустить последнее, код log должен существовать во время выполнения. Это называется сохраненным встроенным методом.

Например:

val pl: PrintLogger = new PrintLogger
val l: Logger = pl
pl.log("msg")
// msg
l.log("msg")
// msg

Встроенные вызовы и динамически отправленные вызовы дают одинаковые результаты.

Любой несохраненный inline 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 параметры.

Парадоксально, но log на интерфейсе InlineLogger нельзя вызвать напрямую. Реализация метода неизвестна статически, и поэтому мы не знаем, что встраивать. Таким образом, вызов абстрактного встроенного метода приводит к ошибке.

Пример:

val pl: PrintLogger = new PrintLogger
pl.log("msg")
val il: InlineLogger = pl
il.log("msg")
|^^^^^^^^^^^^^
|Deferred inline method log in trait InlineLogger cannot be invoked

Полезность абстрактных встроенных методов становится очевидной при использовании в другом встроенном методе:

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

Предположим, вызов для logged конкретного экземпляра PrintLogger:

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

После встраивания вызов log девиртуализируется и становится известно, что он находится на PrintLogger. Поэтому и код log может быть встроен.

Резюме встроенных методов

Отношение к @inline

Scala 2 также определяет @inline аннотацию, которая используется в качестве подсказки для встроенного кода. Модификатор inline является более мощным вариантом, чем аннотация:

Определение константного выражения

Правые части встроенных значений и аргументов для встроенных параметров должны быть константными выражениями в смысле, определенном SLS §6.24, включая специфичные для платформы расширения, такие как свертывание констант чисто числовых вычислений.

Встроенное значение должно иметь литеральный тип, например 1 или true.

inline val four = 4
// equivalent to
inline val four: 4 = 4

Также возможно иметь встроенные значения типов, которые не имеют синтаксиса, например Short(4).

trait InlineConstants:
  inline val myShort: Short

object Constants extends InlineConstants:
  inline val myShort/*: Short(4)*/ = 4

Прозрачные встроенные методы

Прозрачные встроенные строки (transparent 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")
// n0: Int = 0
val s0: String = default("String")
// s0: String = ""

Обратите внимание, что даже если возвращаемый тип метода defaultAny, первый вызов печатается как Int, а второй — как String. Тип возвращаемого значения представляет собой верхнюю границу типа внутри встроенного термина. Также можно было бы быть более точным и написать:

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

Хотя в этом примере кажется, что возвращаемый тип не нужен, он важен, когда встроенный метод является рекурсивным. Тип должен быть достаточно точным для рекурсии.

Ещё пример:

class A
class B extends A:
  def m = true

transparent inline def choose(b: Boolean): A =
  if b then new A else new B

val obj1 = choose(true)  // static type is A
val obj2 = choose(false) // static type is B

// obj1.m // compile-time error: `m` is not defined on `A`
obj2.m    // OK

Здесь встроенный метод choose возвращает экземпляр любого из двух типов A или B. Если бы choose не был объявлен как transparent, результат его раскрытия всегда был бы типа A, даже если вычисляемое значение могло бы иметь подтип B. Встроенный метод является "черным ящиком" в том смысле, что детали его реализации не просачиваются. Но если указан модификатор transparent, расширение является типом расширенного тела. Если аргумент b равен true, то этот тип равен A, иначе — B. Следовательно, вызов m на obj2 пройдет проверку типов, поскольку obj2 имеет тот же тип, что и расширение choose(false), т.е. B. Прозрачные встроенные методы являются "белыми ящиками" в том смысле, что тип приложения такого метода может быть более специализированным, чем его объявленный возвращаемый тип, в зависимости от того, как расширяется метод.

В следующем примере мы видим, как тип возвращаемого значения zero специализирован для одноэлементного типа 0, что позволяет приписать дополнению правильный тип 1.

transparent inline def zero: Int = 0

val one: 1 = zero + 1
Прозрачные элементы влияют на двоичную совместимость Важно отметить, что изменение тела метода transparent inline def изменит способ его вызова. Это означает, что тело играет важную роль в совместимости двоичного кода и исходного кода этого интерфейса.

Прозрачный и непрозрачный inline

Как уже обсуждалось, прозрачные встроенные методы могут влиять на проверку типов в месте вызова. Технически это означает, что прозрачные встроенные методы должны быть расширены во время проверки типов программы. Другие встроенные методы встраиваются позже, когда программа полностью типизирована.

Например, следующие две функции будут типизированы одинаково, но будут встроены в разное время.

inline def f1: T = ...
transparent inline def f2: T = (...): T

Примечательным отличием является поведение transparent inline given. Если при встраивании такого определения сообщается об ошибке, это будет рассматриваться как неявное несоответствие поиска, и поиск будет продолжен. A transparent inline given может добавить описание типа в свой RHS (как в f2 предыдущем примере), чтобы избежать точного типа, но сохранить поведение поиска. С другой стороны, inline given принимается как неявное значение, а затем встраивается после ввода. Любая ошибка будет выдаваться как обычно.

Встроенные match

match выражение в теле определения метода inline может иметь префикс модификатора inline. Как и встроенные if, встроенные match гарантируют, что сопоставление с образцом может быть статически сокращено во время компиляции и сохраняется только одна ветвь. Если статической информации достаточно для однозначного выбора ветви, выражение сокращается до этой ветви и берется тип результата. Если нет, возникает ошибка времени компиляции, которая сообщает, что совпадение не может быть уменьшено.

В приведенном ниже примере определяется встроенный метод с одним встроенным выражением соответствия, которое выбирает case на основе его статического типа:

transparent inline def g(x: Any): Any =
  inline x match
    case x: String => (x, x) // Tuple2[String, String](x, x)
    case x: Double => x

g(1.0d)   // Has type 1.0d which is a subtype of Double
// res5: Double = 1.0
g("test") // Has type (String, String)
// res6: Tuple2[String, String] = ("test", "test")

x проверяется статически, и встроенное совпадение сокращается, возвращая соответствующее значение (со специализированным типом, потому что g объявлен transparent).

Встроенные match предоставляют способ сопоставления статического типа некоторого выражения. Поскольку сопоставляется статический тип выражения, следующий код не будет компилироваться.

val x: Any = "test"
g(x)
// error:
// cannot reduce inline match with
//  scrutinee:  this.x : (App0.this.x : Any)
//  patterns :  case x @ _:String
//              case x @ _:Double
//   inline x match
//          ^

Значение x не помечено как inline и, как следствие, во время компиляции недостаточно информации о проверке, чтобы решить, какую ветвь выбрать.

В примерах выше выполняется простой тест типа над объектом проверки. Тип может иметь более богатую структуру, как простой ADT ниже. toInt соответствует структуре числа в Чёрч-кодировке и вычисляет соответствующее целое число.

trait Nat
case object Zero extends Nat
case class Succ[N <: Nat](n: N) extends Nat

transparent inline def toInt(n: Nat): Int =
  inline n match
    case Zero     => 0
    case Succ(n1) => toInt(n1) + 1

inline val natTwo = toInt(Succ(Succ(Zero)))
val intTwo: 2 = natTwo

Предполагается, что natTwo имеет одноэлементный тип 2.

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] = ...

Детали

Дополнительные сведения о семантике inline см. в документе


Ссылки: