소거되는 정의
소거되는 정의 (Erased Definitions)
erased는 어떤 값이나 파라미터를 컴파일 결과물에 표현하지 않고 컴파일러가 소거(erase)하도록 하는 수식자(modifier)예요. 아직 Scala 언어 표준의 일부는 아니고, experimental.erasedDefinitions라는 언어 기능을 켜야 사용할 수 있어요.
본문
erased는 컴파일러가 어떤 값이나 파라미터를 컴파일된 출력물에 표현하지 않고 소거하도록 하는 수식자예요. 아직 Scala 언어 표준의 일부는 아니에요. erased를 쓰려면 experimental.erasedDefinitions 언어 기능을 켜면 돼요. 언어 import로 켤 수 있고
import scala.language.experimental.erasedDefinitions
또는 커맨드라인 옵션 -language:experimental.erasedDefinitions로 설정할 수 있어요.
왜 erased인가? (Why erased?)
가끔은 어떤 타입이 구성될 수 있다는 증거(evidence)를 제시하기 위해서만 값이 필요하고, 런타임에는 그 값이 참조되지 않을 때가 있어요. 예를 들어 Java 직렬화를 안전하게 만들고 싶다고 해 봐요. 즉 어떤 타입의 값을 직렬화할 때, 그런 타입의 직렬화가 런타임에 실패하지 않는다는 증거를 갖고 싶은 거죠. Java는 java.io.Serializable 인터페이스를 정의해서 이를 확장하는 타입을 직렬화 가능하다고 표시해요. 하지만 이것만으로는 안전하지 않아요. Serializable 클래스가 직렬화할 수 없는 필드를 가질 수 있으니까요. 예를 들어 Scala의 List는 List 데이터를 직렬화할 수 있길 원하므로 Serializable을 확장해요. 그런데 특정 리스트는 직렬화할 수 없는 요소를 가질 수 있어요. 예컨대 함수의 리스트라면 그렇겠죠. 그런 값을 직렬화하려고 하면 NotSerializableException이 던져집니다.
깊게 직렬화 가능한(deeply serializable) 타입에 대해서만 인스턴스를 가진 추가 타입 클래스를 정의하면 직렬화를 안전하게 만들 수 있어요. 예를 들어 이렇게요:
/** Type class for types that are deeply serializable */
trait CanSerialize[T]
inline given CanSerialize[String] = CanSerialize()
inline given [T: CanSerialize] => CanSerialize[List[T]] = CanSerialize()
문자열에 대한 CanSerialize given 인스턴스를 찾을 수 있어요. 문자열은 직렬화 가능하니까요. 그리고 리스트는 그 요소가 직렬화 가능할 때 직렬화 가능하다고 말하는 조건부 given 인스턴스도 찾을 수 있어요. 직렬화 가능한 모든 타입에 대해 (아마 조건부로) 더 많은 인스턴스가 있을 거라고 가정해요.
이제 객체를 ObjectOutputStream으로 직렬화하는 메서드 safeWriteObject를 만들 수 있어요:
def safeWriteObject[T <: java.io.Serializable]
(out: java.io.ObjectOutputStream, x: T)
(using CanSerialize[T]): Unit =
out.writeObject(x)
이 메서드는 타입 파라미터 T의 객체에 대해 동작해요. T는 java.io.Serializable을 따르도록 요구되는데, 그래야 출력 스트림 out의 writeObject 메서드를 그 객체에 쓸 수 있으니까요. 거기에 더해 CanSerialize[T] 타입 클래스 인스턴스가 필요해요. 이는 Java 직렬화가 런타임에 실패하지 않을 것이라는 증거 역할을 하죠. 이 메서드를 리스트 인자로 특수화할 수 있어요. 다음과 같이요:
def writeList[T]
(out: java.io.ObjectOutputStream, xs: List[T])
(using CanSerialize[T]): Unit =
safeWriteObject(out, xs)
writeList를 서로 다른 타입의 리스트에 적용해서 테스트할 수 있어요:
@main def Test(out: java.io.ObjectOutputStream) =
writeList(out, List("a", "b")) // ok
writeList(out, List[Int => Int](x => x + 1, y => y * 2)) // error
첫 번째 호출은 통과하지만, 두 번째 호출은 타입 오류와 함께 거부됩니다:
No given instance of type CanSerialize[Int => Int] was found for parameter x$3 of method writeList
지금까지는 특정 연산을 안전하게 수행할 수 있는 증거를 세우는 전형적인 타입 클래스 패턴이에요. 그런데 이 방식에는 문제가 있어요. 타입 클래스 인스턴스들이 safeWriteObject와 writeList 메서드에 추가 파라미터로 전달되는데, 런타임에는 이 객체들이 어디에서도 사용되지 않아요. 이 파라미터들의 유일한 역할은 특정 타입의 직렬화가 안전하다는 컴파일 타임 증거를 제공하는 거예요. 이 파라미터들을 어떻게든 "소거"해서 런타임에 나타나지 않게 하면 좋겠죠. 그것이 바로 erased가 하는 일이에요. erased를 쓰면 우리 예시는 이렇게 됩니다:
import language.experimental.erasedDefinitions
class CanSerialize[T]
inline given CanSerialize[String] = CanSerialize()
inline given [T: CanSerialize] => CanSerialize[List[T]] = CanSerialize()
def safeWriteObject[T <: java.io.Serializable](out: java.io.ObjectOutputStream, x: T)(using erased CanSerialize[T]) =
out.writeObject(x)
def writeList[T](out: java.io.ObjectOutputStream, xs: List[T])(using erased CanSerialize[T]) =
safeWriteObject(out, xs)
@main def Test(out: java.io.ObjectOutputStream) =
writeList(out, List("a", "b")) // ok
writeList(out, List[Int => Int](x => x + 1, y => y * 2)) // error
safeWriteObject와 writeList의 두 파라미터가 이제 erased라는 점에 주목하세요. 즉 이 파라미터들과 그 인자들이 생성되는 코드에는 존재하지 않는다는 뜻이에요.
erased의 안전 요구사항 중 하나는, 증거를 그냥 지어낼 수 없다는 거예요. 예를 들어 문제가 되는 타입의 given을 지어내서 두 번째 writeList를 통과시키고 싶다고 해 봐요:
writeList(out, List[Int => Int](x => x + 1, y => y * 2))
(using null.asInstanceOfCanSerialize[Int => Int])
이건 방법 중 하나일 뿐이고, 또 다른 방법이 있어요:
def fakeEvidence: CanSerialize[Int => Int] = fakeEvidence
writeList(out, List[Int => Int](x => x + 1, y => y * 2))
(using fakeEvidence)
이런 공격을 배제하기 위해, erased 파라미터의 인자는 순수 표현식(pure expression)이어야 한다고 요구해요. Scala에서 순수한 표현식은 얼마 안 되는데, 다음과 같아요:
- 상수 (constants),
- 지연되지 않은(lazy가 아닌), 불변
val, - 초기화자가 없는 클래스의 생성자를 순수 인자에 적용한 것,
- 초기화자가 없는 케이스 클래스의
apply메서드를 순수 인자에 적용한 것.
다른 함수 호출은 순수 표현식으로 분류되지 않아요. 그래서 우리 예시의 erased 버전에서 두 given 인스턴스가 inline 메서드인 거예요. 인라인된 뒤에는 erased 파라미터의 인자들이 단순한 CanSerialize 클래스 구성이 되고, 이는 순수 표현식으로 간주되니까요.
세부 사항 (Details)
메서드와 함수의 파라미터는 각 소거 파라미터 앞에 erased를 붙여서 선언할 수 있어요(inline처럼).
def methodWithErasedEv(erased ev: Ev, x: Int): Int = x + 2
val lambdaWithErasedEv: (erased Ev, Int) => Int =
(erased ev, x) => x + 2
erased 파라미터는 계산에는 사용할 수 없지만, 다른 erased 파라미터의 인자로는 쓸 수 있어요.
def methodWithErasedInt1(erased i: Int): Int =
i + 42 // ERROR: can not use i
def methodWithErasedInt2(erased i: Int): Int =
methodWithErasedInt1(i) // OK
erased 파라미터의 인자는 순수 표현식이어야 해요.
def f(x: Int): Int =
if x == 0 then 1 else x * f(x - 1)
inline def g(x: Int): Int =
if x == 0 then 1 else x * g(x - 1)
methodWithErasedInt2(5) // ok
methodWithErasedInt2(f(5)) // error, f(22) is not a pure expression
methodWithErasedInt2(g(5)) // ok since `g` is `inline`.
파라미터 말고 val 정의도 erased로 표시할 수 있어요.
이것들도 erased 파라미터의 인자나 다른 erased val 정의의
일부로만 사용할 수 있어요. 게다가 그런 val의
정의 우변(오른쪽)은 순수 표현식이어야 해요.
erased val erasedEvidence: Ev = Ev()
methodWithErasedEv(erasedEvidence, 40) // 42
Erased 트레이트 (The Erased Trait)
어떤 경우에는 트레이트의 모든 인스턴스가 소거되길 기대할 수 있어요. 예를 들어 CanSerialize[T] 인스턴스가 런타임에 존재할 이유가 전혀 없다고 주장할 수도 있어요. 그 경우 CanSerialize가 새 트레이트 compiletimetime.Erased를 확장하게 만들고, erased 파라미터와 val의 명시적 erased 수식자를 생략할 수 있어요. 이 방식을 쓴 우리 예시의 대안 버전이에요:
class CanSerialize[T] extends compiletime.Erased
...
def safeWriteObject[T <: java.io.Serializable](out: java.io.ObjectOutputStream, x: T)(using CanSerialize[T]) = ...
def writeList[T: CanSerialize](out: java.io.ObjectOutputStream, xs: List[T]) = ...
CanSerialize가 Erased를 확장하므로 safeWriteObject의 using 절에서 명시적 erased 수식자를 생략할 수 있어요. 이제 위 writeList 메서드에서 보이는 것처럼 CanSerialize에 대한 컨텍스트 바운드(context bound)를 쓰는 것도 가능해져요. 컨텍스트 바운드는 using 절 (using CanSerialize[T])로 확장되고, 여기에 erased가 암시적으로 붙습니다.
기존 코드에서의 erased 사용 (Uses of Erased in existing Code)
CanThrow[T] 타입 클래스는 예외가 던져질 수 있음을 선언하는 데 쓰여요. 컴파일러는 try에서 처리되는 예외에 대해 CanThrow[E] 인스턴스를 생성해요. 메서드는 예외 E를 던질 수 있음을 나타내기 위해 암시적 CanThrow[E] 파라미터를 받아요. CanThrow는 Erased 능력 클래스로 선언되므로, 런타임에 CanThrow의 실제 증거는 남지 않아요.
다중 보편 동등성(multiversal equality)의 CanEqual 증거는 두 타입이 비교될 수 있는지 확인해요. 실제 비교는 클래스 Object의 보편 equals 메서드나 오버라이딩된 인스턴스가 수행하고, CanEqual 값을 의존하지 않아요. 지금까지 CanEqual은 컴파일러에서 특별하게 처리돼요. erased 정의를 쓰면 CanEqual이 compiletime.Erased를 확장하도록 만들어서 그 특수 처리를 일부 피할 수 있어요.
conforms <:< 타입 클래스는 두 타입이 하위 타입 관계에 있다는 것을 증명할 수 있음을 단언해요. <:<는 값을 업캐스트하는 메서드를 제공하지만, 그건 컴파일러가 생성한 캐스트 연산으로도 제공될 수 있어요. 그 경우 <:<(그리고 =:=)의 런타임 인스턴스는 더 이상 필요하지 않게 되고 소거될 수 있어요.
예시: 소거된 증거를 쓰는 상태 기계 (Example: State machine with erased evidence)
다음 예시는 On 또는 Off 상태에 있을 수 있는 간단한 상태 기계의 확장 구현이에요. 기계는 현재 Off일 때만 turnOn으로 Off에서 On으로 상태를 바꿀 수 있고, 반대로 현재 On일 때만 turnOff로 On에서 Off로 바꿀 수 있어요. 이 제약은 IsOn[T]와 IsOff[T]라는 두 타입 클래스 트레이트로 표현돼요. 이 트레이트들에 대한 두 given 인스턴스는 올바른 종류의 상태에 대해서만 존재해요. IsOn[On]에 대한 given 인스턴스와 IsOff[Off]에 대한 given 인스턴스는 있지만, 다른 조합에 대한 given 인스턴스는 없어요.
turnOn과 turnOff 메서드는 각각 기계가 그 연산을 허용할 올바른 상태에 있음을 보장하기 위해 이 given 인스턴스 중 하나를 요구해요. turnOn과 turnOff가 요구하는 given 인스턴스는 그 함수들의 본문에서 사용되지 않으므로 erased로 표시할 수 있어요.
import language.experimental.erasedDefinitions
import scala.annotation.implicitNotFound
sealed trait State
final class On extends State
final class Off extends State
@implicitNotFound("State must be Off")
class IsOff[S <: State]
object IsOff:
inline given IsOff[Off]()
@implicitNotFound("State must be On")
class IsOn[S <: State]
object IsOn:
inline given IsOn[On]()
class Machine[S <: State]:
// ev will disappear from both functions
def turnOn(using erased IsOff[S]): Machine[On] = new Machine[On]
def turnOff(using erased IsOn[S]): Machine[Off] = new Machine[Off]
@main def test =
val m = Machine[Off]()
val m1 = m.turnOn
val m2 = m1.turnOff
m2.turnOn
// m1.turnOn
// ^ error: State must be Off
// m2.turnOff
// ^ error: State must be On
메서드 test의 첫 네 줄은 모두 유효해요. 주석 처리된 연산들은 유효하지 않아요. m1.turnOn 연산은 유효하지 않아요. m1이 Machine[On] 타입이고, m1.turnOn이 존재하지 않는 given 인스턴스 IsOff[On]을 요구하니까요. m2.turnOff는 비슷한 이유로 유효하지 않아요.
ErasedValue
compiletime.erasedValue 메서드는 컴파일 타임 연산(Compile-time operations)에서 다뤘어요. erasedValue[T] 호출은 소거된 참조로 간주되므로 소거된 컨텍스트에서만, 즉 erased 파라미터의 인자로나 erased val 정의의 우변으로만 사용될 수 있어요. 동시에 erasedValue는 순수 표현식으로 간주되지 않아서, 그런 표현식의 일부가 될 수도 없어요. 최종 효과는 erasedValue에 대한 모든 참조가 인라인으로 제거되어야 한다는 거예요. 이건 의도된 것이에요. erasedValue[T]를 합법적인 erased 표현식으로 허용하면 erased 능력의 안전성이 무너지게 되는데, 소거된 타입의 어떤 값에 대한 증거라도 그것으로 지어낼 수 있게 되니까요.
탈출구(escape hatch)로서 scala.caps.unsafe 객체에는 unsafeErasedValue 메서드도 있어요. scala.caps.unsafe.unsafeErasedValue[T]는 모든 타입 T에 대해 순수 표현식으로 간주되므로 erased 컨텍스트에서 사용할 수 있어요. 하지만 다른 수단으로 세워진 erased 증거가 유효함을 증명할 수 있을 때에만 사용해야 해요.