인라인
인라인 (Inline)
inline은 정의가 사용 지점에서 인라인될 것을 보장하는 새 소프트 수식어(soft modifier)예요. 이 글에서는 인라인 값과 인라인 메서드, 재귀 인라인, transparent 인라인, 인라인 조건문과 인라인 매치를 차례로 살펴볼게요.
본문
인라인 정의 (Inline Definitions)
inline은 정의가 사용 지점에서 인라인될 것을 보장하는 새 소프트 수식어예요. 예시를 볼게요.
object Config:
inline val logging = false
object Logger:
private var indent = 0
inline def log[T](msg: String, indentMargin: =>Int)(op: => T): T =
if Config.logging then
println(s"${\" \" * indent}start $msg")
indent += indentMargin
val result = op
indent -= indentMargin
println(s"${\" \" * indent}$msg = $result")
result
else op
end Logger
Config 객체에는 인라인 값(inline value) logging의 정의가 있어요. 이 말은 logging이 상수 값으로 취급되며, 그 우변 false와 동등하다는 뜻이에요. 이런 inline val의 우변은 그 자체가 상수 표현식이어야 해요. 이렇게 쓰면 inline은 자바와 스칼라 2의 final과 동등해요. 참고로 인라인 상수를 뜻하는 final은 스칼라 3에서 여전히 지원되지만, 앞으로 단계적으로 제거될 거예요.
Logger 객체에는 인라인 메서드(inline method) log의 정의가 있어요. 이 메서드는 호출 지점에서 항상 인라인돼요.
인라인된 코드에서 상수 조건을 가진 if-then-else는 그 then-부분이나 else-부분으로 재작성돼요. 따라서 위 log 메서드에서 Config.logging == true인 if Config.logging은 then-부분으로 재작성돼요.
예시를 볼게요.
var indentSetting = 2
def factorial(n: BigInt): BigInt =
log(s"factorial($n)", indentSetting) {
if n == 0 then 1
else n * factorial(n - 1)
}
Config.logging == false라면, 이 코드는 (단순화되어) 이렇게 재작성돼요.
def factorial(n: BigInt): BigInt =
if n == 0 then 1
else n * factorial(n - 1)
보시다시피 msg나 indentMargin이 쓰이지 않으므로, factorial용 생성 코드에는 나타나지 않아요. 또 log 메서드의 본문을 주목해 보세요. else- 부분은 그냥 op로 줄어들어요. 생성 코드에서 우리는 by-name 파라미터를 한 번만 참조하므로 어떤 클로저도 만들지 않아요. 결과적으로 코드가 직접 인라인되고 호출이 베타 축소(beta-reduce)됐어요.
true의 경우 코드는 이렇게 재작성돼요.
def factorial(n: BigInt): BigInt =
val msg = s"factorial($n)"
println(s"${\" \" * indent}start $msg")
Logger.inline$indent_=(indent.+(indentSetting))
val result =
if n == 0 then 1
else n * factorial(n - 1)
Logger.inline$indent_=(indent.-(indentSetting))
println(s"${\" \" * indent}$msg = $result")
result
by-value 파라미터 msg는 일반적인 스칼라 시맨틱에 따라 정확히 한 번 평가된다는 걸 주목하세요. 값을 바인딩하고 factorial의 본문을 통해 msg를 재사용하는 식이죠. 또 private var indent에 대한 대입의 특별 처리를 주목하세요. 이는 세터 메서드 def inline$indent_=를 생성해서 그걸 호출함으로써 이루어져요.
인라인 메서드는 항상 완전히 적용(fully applied)되어야 해요. 예를 들어 다음과 같은 호출은
Logger.log[String]("some op", indentSetting)
잘못된 형태라서 컴파일러가 인자가 빠졌다고 지적해요. 하지만 와일드카드 인자를 넘기는 건 가능해요. 예를 들어,
Logger.log[String]("some op", indentSetting)(_)
는 타입 체킹을 통과해요.
재귀 인라인 메서드 (Recursive Inline Methods)
인라인 메서드는 재귀적일 수 있어요. 예를 들어 상수 지수 n과 함께 호출되면, power 메서드는 루프나 재귀 없이 쭉 이어진 인라인 코드로 구현돼요. 연속 인라인 횟수는 32로 제한되어 있고 컴파일러 설정 -Xmax-inlines로 바꿀 수 있다는 점을 알아 두면 좋아요.
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
인라인 메서드의 파라미터에도 inline 수식어를 붙일 수 있어요. 이 말은 이 파라미터에 대한 실제 인자가 inline def의 본문에 인라인된다는 뜻이에요. inline 파라미터는 by-name 파라미터와 같은 호출 시맨틱을 가지지만, 인자에서 코드의 중복을 허용해요. 상수 값을 전파해서 추가 최적화·축소를 가능하게 할 때 보통 유용해요.
다음 예시는 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()}")
오버라이딩 규칙 (Rules for Overriding)
인라인 메서드는 다른 비인라인 메서드를 오버라이드할 수 있어요. 규칙은 다음과 같아요.
- 인라인 메서드
f가 다른 비인라인 메서드를 구현하거나 오버라이드한다면, 그 인라인 메서드는 런타임에도 호출될 수 있어요. 예를 들어 다음 시나리오를 고려해 볼게요.
abstract class A:
def f: Int
def g: Int = f
class B extends A:
inline def f = 22
override inline def g = f + 11
val b = new B
val a: A = b
// inlined invocatons
assert(b.f == 22)
assert(b.g == 33)
// dynamic invocations
assert(a.f == 22)
assert(a.g == 33)
인라인된 호출과 동적 디스패치된 호출은 같은 결과를 줘요.
-
인라인 메서드는 사실상 final이에요.
-
인라인 메서드는 추상일 수도 있어요. 추상 인라인 메서드는 다른 인라인 메서드에 의해서만 구현될 수 있어요. 직접 호출할 수는 없어요.
abstract class A:
inline def f: Int
object B extends A:
inline def f: Int = 22
B.f // OK
val a: A = B
a.f // error: cannot inline f in A.
@inline과의 관계 (Relationship to @inline)
스칼라 2에는 코드를 인라인하라는 백엔드용 힌트로 쓰이는 @inline 어노테이션도 있어요. inline 수식어는 더 강력한 선택지예요.
- 확장이 최선 노력(best effort) 대신 보장돼요.
- 확장이 백엔드가 아니라 프론트엔드에서 일어나요.
- 확장이 재귀 메서드에도 적용돼요.
상수 표현식의 정의 (The definition of constant expression)
인라인 값의 우변과 인라인 파라미터의 인자는 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 인라인 메서드 (Transparent Inline Methods)
인라인 메서드는 추가로 transparent로 선언할 수 있어요. 이 말은 인라인 메서드의 반환 타입이 확장 시 더 정밀한 타입으로 특수화될 수 있다는 뜻이에요. 예시를 볼게요.
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로 선언되지 않았다면, 그 확장의 결과는 계산된 값이 서브타입 B일지라도 항상 타입 A였을 거예요. 인라인 메서드는 구현 세부 사항이 새지 않는다는 점에서 "블랙박스"예요. 하지만 transparent 수식어가 주어지면 확장은 확장된 본문의 타입이 돼요. 인자 b가 true라면 그 타입은 A, 그렇지 않으면 B예요. 따라서 obj2에 대해 m을 호출하는 것은 타입 체킹을 통과해요. obj2가 choose(false)의 확장과 같은 타입, 즉 B이기 때문이죠. Transparent 인라인 메서드는 그러한 메서드의 적용 타입이, 어떻게 확장되는지에 따라 선언된 반환 타입보다 더 특수화될 수 있다는 점에서 "화이트박스"예요.
다음 예시에서는 zero의 반환 타입이 싱글턴 타입 0으로 특수화되어, 덧셈이 올바른 타입 1로 지정될 수 있는 걸 볼 수 있어요.
transparent inline def zero: Int = 0
val one: 1 = zero + 1
Transparent vs. 비-transparent 인라인 (Transparent vs. non-transparent inline)
앞에서 논의했듯이, transparent 인라인 메서드는 호출 지점의 타입 체킹에 영향을 줄 수 있어요. 기술적으로 이는 transparent 인라인 메서드가 프로그램의 타입 체킹 중에 확장되어야 한다는 뜻이에요. 다른 인라인 메서드는 프로그램이 완전히 타입 체킹된 뒤에 인라인돼요.
예를 들어 다음 두 함수는 같은 방식으로 타입 체킹되지만, 다른 시점에 인라인돼요.
inline def f1: T = ...
transparent inline def f2: T = (...): T
주목할 만한 차이점은 transparent inline given의 동작이에요. 그 정의를 인라인할 때 오류가 보고되면, 그것은 implicit 검색 불일치로 간주되고 검색이 계속돼요. transparent inline given은 그 RHS에 타입 지정을 추가해서(앞 예시의 f2처럼) 정밀 타입을 피하면서도 검색 동작은 유지할 수 있어요. 반면 inline given은 implicit으로 받아들여진 뒤 타입 체킹 후에 인라인돼요. 어떤 오류든 평소처럼 방출돼요.
인라인 조건문 (Inline Conditionals)
조건이 상수 표현식인 if-then-else 표현식은 선택된 분기로 단순화될 수 있어요. if-then-else 표현식 앞에 inline을 붙이면 조건이 상수 표현식이어야 함을 강제해서, 조건문이 항상 단순화될 것을 보장해요.
예시:
inline def update(delta: Int) =
inline if delta >= 0 then increaseBy(delta)
else decreaseBy(-delta)
호출 update(22)는 increaseBy(22)로 재작성돼요. 하지만 update를 컴파일 타임 상수가 아닌 값으로 호출하면, 아래 같은 컴파일 타임 오류가 나요.
| inline if delta >= 0 then ???
| ^
| cannot reduce inline if
| its condition
| delta >= 0
| is not a constant value
| This location is in code that was inlined at ...
transparent 인라인에서 inline if는 타입 체킹 중에 그 조건의 어떤 인라인 정의든 강제로 인라인시켜요.
인라인 매치 (Inline Matches)
inline 메서드 정의의 본문에 있는 match 표현식은 inline 수식어로 접두될 수 있어요. 컴파일 타임에 분기를 선택할 충분한 타입 정보가 있다면, 그 표현식은 그 분기로 축소되고 표현식의 타입은 그 결과의 우변 타입이 돼요. 그렇지 않으면 매치를 축소할 수 없다고 보고하는 컴파일 타임 오류가 발생해요.
아래 예시는 정적 타입을 기준으로 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
g("test") // Has type (String, String)
심판값(scrutinee) x를 정적으로 검사하고 인라인 매치를 그에 따라 축소해서 대응하는 값을 반환해요(g가 transparent로 선언됐으므로 타입이 특수화됨). 이 예시는 심판값에 대해 간단한 타입 테스트를 수행해요. 아래의 간단한 ADT처럼 타입이 더 풍부한 구조를 가질 수도 있어요. toInt는 Church 인코딩에서 숫자의 구조를 매치하고 대응하는 정수를 계산해요.
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로 추론돼요.
참고 (Reference)
inline의 시맨틱에 대한 자세한 내용은 "Scala 2020: Semantics-preserving inlining for metaprogramming" 논문을 보세요. (여기에서도 볼 수 있어요.)