자동 파라미터 언롤링
자동 파라미터 언롤링 (Automatic Parameter Unrolling)
메서드나 클래스에 새 파라미터를 추가하면서도 기존 바이너리 호환성을 지킬 수 있다면 참 편리하겠죠. @unroll 어노테이션은 기본값(default value)을 가진 파라미터에 붙이면, 메서드나 생성자를 위한 하위 호환 포워더(forwarder)를 자동으로 생성해 줘요.
본문
다음은 @unroll이 무슨 일을 하는지 보여주는 예시예요.
// V1
final def foo(
s: String,
i: Int
): String = s + i
위 예시에서 어떤 라이브러리의 버전 V1이 두 개의 파라미터 s와 i를 가진 메서드 foo를 정의한다고 가정해 볼게요. 그리고 클라이언트 라이브러리나 애플리케이션 C1이 이 V1의 foo에 맞춰 컴파일된 상태라고 해봐요.
// V2
final def foo(
s: String,
i: Int,
@unroll b: Boolean = true,
l: Long = 0L
): String = s + i + b + l
// Generated automatically
`<invisible>` final def foo(
s: String,
i: Int
) = foo(s, i, true, 0L)
버전 V2에서 라이브러리는 foo에 b와 l 파라미터를 기본값과 함께 추가했어요. V1과의 호환성을 지키기 위해 b에 @unroll을 붙였죠. 그러면 V1에서처럼 앞쪽 파라미터만을 갖는, 즉 V1의 foo와 같은 시그니처를 가진 포워더가 생성돼요.
V2에 맞춰 컴파일하는 클라이언트 C2는 공개 API에서 네 개의 파라미터를 가진 foo만 보게 돼요. 생성된 포워더는 그런 클라이언트에게는 숨겨져 있어요. 그런데도 C1은 V2 라이브러리와 계속 호환되므로 다시 컴파일할 필요가 없어요. 런타임에는 옛 foo 메서드의 시그니처로 계속 링크되면서, 바이너리 API에서 접근 가능한 생성된 포워더를 호출하게 되는 거죠.
명세 (Specification)
@unroll 어노테이션
scala.annotation.unroll 어노테이션은 사실상 final인 메서드의 임의의 term 파라미터에 적용할 수 있어요.
object안의def(즉final을 생략해도 됨)class나trait안의final defclass파라미터 (즉 기본 생성자)class안의def this(즉 보조 생성자)
제약 (Restrictions)
@unroll을 다른 정의에 적용하는 것은 불법이에요. (trait 파라미터나 로컬 메서드 포함) 타입을 어노테이션하는 것도 안 돼요.
@unroll은 메서드당 여러 파라미터에 적용할 수 있지만, 모든 적용 지점이 같은 파라미터 절(clause) 안에 있어야 해요.
어노테이션이 붙은 파라미터와, 같은 파라미터 절 안에서 그것보다 오른쪽에 있는 모든 파라미터는 기본값을 가져야 해요.
생성된 포워더가 같은 클래스 안의 다른 선언과 시그니처가 겹치면 컴파일 타임 오류가 나요.
코드 생성 (Code generation)
@unroll 파라미터의 확장은 TASTy 생성 전에 일어나므로, 생성된 코드는 TASTy에 나타나요.
아래는 일어나는 변환을 설명한 내용이에요.
템플릿의 각 메서드 m마다, @unroll을 검사할 대상 메서드 t가 있어요.
- 케이스 클래스
C의 컴패니언에 있는fromProduct,copy,apply라면,t는C의 기본 생성자예요. - 그 외에는
m이 곧t예요.
t가 @unroll 어노테이션이 붙은 단일 파라미터 리스트를 가진다면, m은 코드 생성의 대상이 돼요. 여기에는 두 가지 변환이 가능해요.
- 포워더 생성 (Forwarder generation)
- 재구현 (Reimplementation): 케이스 클래스 컴패니언의
fromProduct용
(1) 포워더 생성
파라미터 리스트 i에 언롤된 파라미터들을 가진 메서드 foo에서, @unroll 어노테이션이 붙은 각 파라미터 p는 정확히 하나의 포워더 메서드 f_p를 생성해요.
일반적인 시그니처를 가진 메서드가
final def foo[T](ps0...)(psX..., @unroll p, psY...)(psN...): T =
...
이렇게 있다면, f_p는 다음과 같은 형태를 가져요.
`<invisible>` final def foo[T](ps0...)(psX...)(psN...): T =
foo(ps0...)(psX..., p_D, psY_D...)(psN...)
즉, 결과 타입은 보존되고, i 앞뒤의 파라미터 리스트는 그대로예요. 그리고 i 안에서는,
p의 왼쪽에 있는 파라미터psX...는 보존되고,- 파라미터
p와psY...는 빠져요.
f_p의 본문에서 파라미터들은 위치 그대로 원래 foo에 전달돼요. 다만 빠진 파라미터들(p와 psY...)은 그에 해당하는 기본 인자(p_D와 psY_D...)로 대체돼요.
포워더는 타입 체킹 이후, pickling 이전에 Invisible 플래그를 가지고 생성돼요. 이 말은 TASTy에는 존재하지만, 다른 최상위 클래스에서는 해석(resolve)할 수 없다는 뜻이에요.
포워더 메서드 파라미터는 기본값을 가지지 않고, 절대 @unroll로 어노테이션되지 않아요.
(2) 메서드 재구현
fromProduct의 시맨틱 호환성을 지키기 위해, 그 본문은 파라미터의 productArity에 대한 패턴 매치로 대체돼요. 케이스 클래스 기본 생성자에 대해 생성된 각 포워더마다, 패턴 매치에 동등한 case가 생성돼요.
예를 들어 포워더가
`<invisible>` def this(ps...) = this(ps..., ds...)
이렇게 생겼다면, 다음 case가 생성돼요.
case n => new C(...p.productElement(n - 1), ds...)
여기서 n은 ps의 파라미터 수와 일치하는 정수예요.
패턴 매치는 기본 와일드카드 case를 갖는데, 이 case의 본문은 원래 fromProduct 메서드의 본문과 같아요.
전체 변환을 종합하면 이렇게 돼요.
case class C(ps0...) // ps0 has z parameters
object C:
def fromProduct(p: Product): C =
p.productArity match
case ... => ...
case n => new C(...p.productElement(n - 1), ds...)
case _ => new C(...p.productElement(z - 1))
배경 동기 (Background Motivation)
스칼라 언어 라이브러리 생태계는 TASTy 포맷(TASTy 호환성)과 JVM 클래스 파일 포맷(바이너리 호환성) 둘 다로 표현되는 API의 호환성에 기반해 있어요.
메서드나 생성자에 파라미터를 추가하는 것은 바이너리 하위 호환을 깨뜨리는 변경이에요. 이전 버전에 맞춰 컴파일된 클라이언트는 옛 시그니처가 존재할 거라고 기대하기 때문에, 런타임에 LinkageError가 발생하게 되죠. 호환성을 지키기 위한 올바른 해법은 새 파라미터를 추가하기 전에 메서드를 복제해 두는 거예요.
실제로 스칼라 사용자들은 API를 진화시킬 때 이 문제를 완화하기 위한 여러 기법과 규율을 만들어 왔어요. 케이스 클래스 같은 특정 기능을 금지하거나, 다양한 코드 생성 프레임워크를 쓰는 식이죠. 잘 알려진 예로는 다음이 있어요.
- data-class
- SBT Contraband
- Structural Data Structures
@unroll 어노테이션은 스칼라 위에 새로운 메타언어를 배울 필요 없이 이 문제를 해결하는 대안으로 제안됐어요. def, case class, enum, class, trait 같은 표준 데이터 모델링 기법은 그대로 두고, 실수하기 쉬운 보일러플레이트만 자동화하는 거죠.