devcken.io

Thoughts, stories and ideas.

Overcoming type erasure in Scala

원문: Overcoming type erasure in Scala

이 글을 통해 스칼라의 타입 이레이저로 인해 흔히 발생되는 몇 가지 문제들을 다루기 위한 서너개의 기술들을 소개하고자 합니다.

소개

스칼라는 정말 강력한 타입 시스템을 갖추고 있습니다. 존재하는 타입, 구조적 타입, 내재화된 타입, 경로 기반의 타입, 추상 및 구체 타입 멤버, 타입 바운드(상위, 하위, 뷰, 컨텍스트), 사용 위치 및 선언 위치 타입 변성, 타입 다형성을 위한 지원(서브타입, 파라메터, F-바운드, 애드혹), 고계 타입, 보편적 타입 제약 등 셀 수 없을 정도입니다.

스칼라의 타입 시스템이 이론적으로 매우 강력하다고 해도, 런타임 환경의 제약과 제한으로 인해 실제로는 일부 타입 관련 기능들이 약화됩니다. 맞아요. 타입 이레이저에 대해 이야기하려고 하는 것입니다.

타입 이레이저란 무엇일까요? 음, 간단히 말하자면, 자바와 스칼라 컴파일러에 의해 실행된 프로시저로 컴파일 후 모든 제네릭 타입 정보를 제거합니다. 즉, 예를 들면, 런타임에 List[Int]List[String] 간의 차이를 구별할 수 없다는 것을 의미합니다. 컴파일러는 왜 그렇게 할까요? 음, 자바 가상 머신(자바와 스칼라를 실행하는 내재된 런타임 환경)이 제네릭에 대해 어떤 것도 알고 있지 않기 때문입니다.

타입 이레이저는 역사적인 이유로 생겨났습니다. 자바가 처음부터 제네릭을 지원한 것은 아닙니다. 그래서 자바 5에 추가됐을 때, 하위 호환성을 유지해야만 했습니다. 과거의, 제네릭하지 않은 레거시 코드와 무리없이 인터페이스할 수 있길 원했습니다(이것이 바로 자바가 로우 타입을 갖는 이유입니다). 내부에서는 제네릭 클래스의 타입 파라메터가 객체와 상위 바운드로 대체됩니다. 예를 들면:

class Foo[T] {  
  val foo: T
}
class Bar[T <: Something] {  
  val bar: T
}

는 다음과 같이 변경됩니다

class Foo {  
  val foo: Object
}
class Bar {  
  val bar: Something
}

보다시피, 런타임은 제네릭 클래스가 파라메터화된 실제 클래스에 대해 알지 못합니다. 예제에서, 가공되지 않은 FooBar만 알 수 있을 뿐입니다.

타입 이레이저가 누군가의 무능함 또는 무지 등으로 인해 만들어진 산물이라고 생각하지 마십시오. 그것은 나쁜 설계가 아닙니다(누군가 충분히 영리하지 못하거나 충분히 유능하지 못한 사람의 산물이라고 한적이 있습니다). 그것은 의도된 트레이드 오프입니다. 소스, 바이너리 그리고 동작 호환성을 다룰 때 고려해야 할 많은 것들이 존재하며, 자바 담당자들은 이를 위해 많은 시간을 들였고 그들이 생각할 수 있는 최상의 작업을 해냈습니다. 개인적으로, 그냥 하위 호환성을 깨고 이후의 자바 릴리즈의 제네릭을 사용하도록 강제하는 것이 장기적으로는 더 나은 결정이라고 생각합니다. 그러나 비지니스 측면에서, 그들의 결정은 충분히 이해할 만합니다. 고객의 중요한 부분들을 복잡하게 만들어버리는 것(그리고 어쩌면 고객을 화나게 만들 수도 있는 것)을 선택하기란 쉽지 않기 때문입니다.

어쨌든, 본론으로 돌아가서, 스칼라에서 타입 이레이저를 다룰 수 있는 방법에 대해 이야기하고자 합니다. 불행하게도, 타입 이레이저 자체를 막을 방법은 없지만, 몇 가지 해결 방법에 대해 알아보도록 하겠습니다.

동작 방식(또는 동작하지 않는 방식)

다음은 타입 이레이저에 대한 간단한 예제입니다:

object Extractor {  
  def extract[T](list: List[Any]) = list.flatMap {
    case element: T => Some(element)
    case _ => None
  }
}

val list = List(1, "string1", List(), "string2")  
val result = Extractor.extract[String](list)  
println(result) // List(1, string1, List(), string2)  

extract() 메서드는 모든 종류의 객체에 대한 리스트를 받습니다. Any 타입을 가지고 있어서, 숫자, 부울 값, 문자열, 바나나, 오렌지 등 무엇이든지 넣을 수 있습니다. 어쨌든, 코드 내에서 List[Any]가 나오면 "코드 냄새"를 바로 맡을 수 있어야 하지만, 잠시 모범 사례는 제쳐두고 타입 이레이저와 관련된 문제에만 집중하도록 하겠습니다.

우리가 하고자 하는 것은 혼합된 객체 리스트를 받고 특정 타입의 객체만 추출하는 것입니다. 그러한 타입으로 extract() 메서드를 파라메터화하도록 타입을 선택할 수 있습니다. 주어진 예제에서 선택된 타입은 String인데, 이는 주어진 리스트에서 모든 문자열을 추출하려고 한다는 것을 의미합니다.

(런타임 세부 내용과는 관련없이) 엄격한 언어 관점에서 이 코드는 합리적입니다. 패턴 매칭으로 주어진 객체를 분해하는 과정없이 타입을 알아낼 수 있습니다. 하지만, JVM에서 실행되고 있는 프로그램이라는 점에서, 모든 제네릭 타입은 컴파일 이후에 지워질 것입니다. 그러므로 패턴 매칭은 실제로 오래가지 못합니다. 타입의 "일급 수준"을 넘어서는 모든 것이 삭제되기 때문입니다. 변수를 Int 또는 String(또는 MyNonGenericClass와 같이 제네릭하지 않은 모든 타입)에 매치시키는 것은 잘 동작하지만, T를 제네릭 파라메터라고 할 때, T에 매치시키는 것은 불가능합니다. 컴파일러는 "abstract type pattern T is unchecked since it is eliminated by erasure"라는 경고를 줄 것입니다.

이러한 상황에 대해 도움을 주고자, 스칼라는 2.7 버전쯤부터 Manifest를 도입했습니다. 하지만, 특정 타입을 표현할 수 없다는 문제를 가지고 있었고 스칼라 2.10 에서 좀 더 강력한 [TypeTag](http://docs.scala-lang.org/overviews/reflection/typetags-manifests.html)로 대체되었습니다.

타입 태그는 다음과 같이 세 가지 부류로 나뉩니다:

  • TypeTag
  • ClassTag
  • WeakTypeTag

이것이 문서의 공식 분류이긴 하지만, 제 생각에는 다음과 같이 분류하는 것이 더 나아보입니다:

  • TypeTag:
    • "classic"
    • WeakTypeTag
  • ClassTag

TypeTag와 WeakTypeTag가 실제로 (나중에 설명할) 한가지 중요한 차이점만 있는 동일한 두 가지 유형인 반면 ClassTag는 상당히 다른 구조라는 점을 강조하고자 합니다.

ClassTag

추출기 예제로 돌아가서 타입 이레이저 문자를 수정하는 방법에 대해 살펴보겠습니다. 단일한 암시 파라메터를 extract() 메서드에 추가하는 것이 전부입니다:

import scala.reflect.ClassTag  
object Extractor {  
  def extract[T](list: List[Any])(implicit tag: ClassTag[T]) =
    list.flatMap {
      case element: T => Some(element)
      case _ => None
    }
}
val list: List[Any] = List(1, "string1", List(), "string2")  
val result = Extractor.extract[String](list)  
println(result) // List(string1, string2)  

짜잔! 갑자기 "List(string1, string2)"라고 표시될 겁니다. 물론 여기서 컨텍스트 바운드 문법을 사용할 수도 있습니다:

// def extract[T](list: List[Any])(implicit tag: ClassTag[T]) =
def extract[T : ClassTag](list: List[Any]) =  

코드를 가능한 선명하게 보이도록 어떤 문법적 편의도 사용하지 않고 표준 문법을 사용할 겁니다.

어떻게 동작하나요? 음, ClassTag 타입인 암시 값을 요구하면 컴파일러가 그 값을 생성합니다. 문서에서는 이렇게 말하고 있습니다:

u.ClassTag[T] 타입의 암시 값이 요구되는 경우, 컴파일러는 필요에 따라 하나의 값을 만들어 냅니다.

그래서, 컴파일러는 요구되는 ClassTag의 암시 인스턴스를 기꺼이 제공하며, 우리는 그냥 요구하면 됩니다. 또한 이러한 메커니즘은 TypeTagWeakTypeTag에도 사용됩니다.

좋아요, extract() 메서드에서 사용 가능한 암시 ClassTag 값을 갖게 되었습니다(고마워요, 컴파일러). 메서드 본문 내로 들어가면 무슨 일이 벌어질까요? 다시 한번 예제를 보시길 바랍니다. 컴파일러는 자동으로 암시 파라메터 태그에 대한 값을 우리에게 제공해주었을 뿐(그 자체로도 훌륭합니다), 결코 해당 파라메터 자체를 사용한 적은 없습니다. "태그" 값으로 절대 어떤 일도 해서는 안됩니다. 그것은 패턴 매칭이 리스트의 문자열 요소에 성공적으로 매치되도록 하는 존재에 불과합니다. 좋아요, 그것은 컴파일러의 매우 훌륭한 점이지만, 너무 "마법같은 일"들이 계속되고 있는 것처럼 보입니다. 더 자세히 살펴보겠습니다.

설명을 찾아보기 위해 문서를 볼 수도 있지만, 실제로는 여기에 숨어있습니다:

컴파일러는 a(_: T) 타입 패턴을 ct(_: T)으로 래핑하여 패턴 매치의 확인되지 않은 타입 테스트를 확인된 것으로 바꾸려고 합니다. 여기서 ctClassTag[T]의 인스턴스를 말합니다.

기본적으로는 암시 ClassTag를 컴파일러에게 제공하면, 주어진 태그를 추출기(extractor, 역자: 예제의 extractor가 아닌 패턴 매치의 extractor를 말함)를 사용하도록 패턴 매칭의 조건을 다시 작성합니다. 다음 조건

case element: T => Some(element)  

는 컴파일러에 의해 (스코프 내에 암시 태그가 존재하는 경우) 다음과 같이 변환됩니다:

case (element @ tag(_: T)) => Some(element)  

여러분이 "@" 구조를 이전에 보지 못했을 수도 있는데, 매치하고 있는 클래스에 이름을 부여하는 방법일 뿐입니다. 예를 들어:

case Foo(p, q) => // we can only reference parameters via p and q  
case f @ Foo(p, q) => // we can reference the whole object via f  

사용할 T 타입에 대해 사용 가능한 암시 ClassTag가 없는 경우, (타입 정보의 결여로 인해) 컴파일러는 작동하지 않을 것이고 패턴 매칭이 T 타입에 대해 타입 이레이저를 겪을 것이라는 경고를 내보냅니다. 컴파일이 중단되지는 않지만, 패턴 매칭에 도달했을 때 패턴 매칭을 할 때 T가 무엇인지를 컴파일러가 알 거라고 기대할 수 없습니다(JVM에 의해 런타임이 타입이 지워지기 때문에). 만약 타입 T에 대해 암시 ClassTag를 제공할 경우, 컴파일러는 예제에서 보았던 것처럼 컴파일 시점에 적합한 ClassTag를 기꺼이 제공할 것입니다. 태그는 String이 될 T에 관한 정보를 줄 것이며 타입 이레이저는 그것을 건들이지 못합니다.

좋지 않나요? 그러나 중대한 약점이 한 가지 있습니다. 고차 수준의 타입을 구분하여 초기 리스트에서 예를 들어 List[String]는 무시하면서 List[Int] 값을 가져오려는 경우, 그와 같이 할 수는 없습니다:

val list: List[List[Any]] = List(List(1, 2), List("a", "b"))  
val result = Extractor.extract[List[Int]](list)  
println(result) // List(List(1, 2), List(a, b))  

웁스! 오직 List[Int]만 추출하려고 했는데, List[String] 역시 추출되었습니다. 클래스 태그는 고차 수준을 구별할 수 없습니다. 최상위 수준만 가능합니다. 즉, 추출기는 예를 들어 세트와 리스트를 구분할 수 있지만, 한 리스트를 다른 리스트와 구분할 수는 없습니다(예를 들어 List[Int] vs List[String]). 물론 리스트에만 해당되는 얘기는 아니며 모든 제네릭 트레이트/클래스에도 해당되는 얘기입니다.

TypeTag

ClassTag가 실패하는 경우, TypeTag는 훌륭하게 성공합니다. List[String]List[Integer]와 구분합니다. List[Set[Int]]List[Set[String]]와 구분하듯이, 더 깊게 들어갈 수 있습니다. TypeTag가 런타임에 제네릭 타입에 관한 더 풍부한 정보를 가지고 있기에 가능합니다. 문제가 되는 타입의 전체 경로뿐만 아니라 (존재한다면) 모든 내재화된 타입까지도 쉽게 가져올 수 있습니다. 주어진 태그에 tpe()을 호출하기만 하면 이러한 정보를 얻을 수 있습니다.

다음 예제에서, ClassTag의 경우와 마찬가지로 암시 태그 파라메터가 컴파일러에 의해 주어집니다. "args" 인자에 주목하시기 바랍니다. 이 인자는 ClassTag가 가지고 있지 않은 추가적인 타입 정보(Int로 파라메터화된 List에 대한 정보)를 가진 인자입니다.

import scala.reflect.runtime.universe._  
object Recognizer {  
  def recognize[T](x: T)(implicit tag: TypeTag[T]): String =
    tag.tpe match {
      case TypeRef(utype, usymbol, args) =>
        List(utype, usymbol, args).mkString("\n")
    }
}

val list: List[Int] = List(1, 2)  
val result = Recognizer.recognize(list)  
println(result)  
// prints:
//   scala.type
//   type List
//   List(Int)

(어쩌면 의존성을 추가해야 할 수도 있습니다).

여기서 새로운 객체가 도입되었습니다. 바로 Recognizer입니다. 괜찮은 예전 Extractor에 무슨 일이 생겼나요? 음, 슬픈 소식입니다. TypeTags를 사용해 Extractor를 구현할 수 없습니다. 좋은 점은 고차 타입에 관해 알고 있듯이(즉, List[X]List[Y]와 구분하는 것), 타입에 관한 더 많은 정보를 가지고 있는 것인데, 단점은 런타임 객체에 사용할 수 없다는 것입니다. 우리는 TypeTag를 사용해 런타임에 특정 타입에 관한 정보를 가져올 수는 있지만, 런타임 시 어떤 객체의 타입을 알아내기 위해 사용할 수는 없습니다. 차이점을 아시겠나요? recognize()에 전달하는 것은 확실히 List[Int]였습니다. List(1, 2) 값의 선언 타입이었죠. 그러나 List(1, 2)가 만약 List[Any]로 선언되었다면, TypeTagList[Any]가 전달되었다고 알려줄 것입니다.

다음은 ClassTagTypeTag 간의 차이점입니다:

  1. ClassTag는 "고차 타입"에 관해 알지 못합니다. List[T]가 주어지면, ClassTag는 값이 List라는 것을 알뿐 T에 관해서는 알지 못합니다.
  2. TypeTag는 "고차 타입"에 관해 알고 훨씬 더 풍부한 타입 정보를 가지고 있지만, 런타임 시 값에 대한 타입 정보를 얻는데 사용할 수는 없습니다. 다시 말해, TypeTag타입에 관한 런타임 정보를 제공하지만, ClassTag값에 대한 런타임 정보(구체적으로 말해서, 런타임 시 문제가 되는 값의 실제 타입이 무엇인지를 알려주는 정보)를 제공합니다.

ClassTag(Weak)TypeTag 간의 차이점을 떠나 한 가지 더 언급해야 할 것이 있습니다. ClassTag는 고전적이고 훌륭한 오래된 타입 클래스입니다. 그것은 각 타입에 대한 개별적인 정보와 함께 제공되어, 표준 타입 클래스 패턴이 됩니다. 반면, (Weak)TypeTag는 좀 더 정교하며 이전에 사용한 코드 조각에서 알 수 있듯이 코드에 특별한 import를 두어야 합니다. universe를 임포트해야 합니다:

Universe는 멤버쉽 또는 서브타이핑과 같은 스칼라 타입 관계를 리플렉션으로 조사할 수 있도록 하는 리플렉션 연산의 전체 세트를 제공합니다.

걱정하지 마세요, 단순히 universe를 임포트하는 것이 전부입니다. (Weak)TypeTag의 경우에는 scala.reflect.runtime.universe._(문서)를 임포트하세요.

WeakTypeTag

ClassTag와 관련해 지금까지 설명했던 모든 차이점으로 아마도 TypeTagWeakTypeTag가 꽤 유사하다는 인상을 받고 있을 겁니다. 그리고 사실 그렇습니다. 그 둘은 실제로 동일한 도구의 두 가지 변형입니다. 그러나, 중요한 차이점이 존재합니다.

TypeTag가 타입 뿐만 아니라 타입의 파라메터 그리고 그들의 타입 파라메터 까지도 조사할 정도로 충분히 스마트하다는 것을 보았습니다. 하지만, 그러한 모든 타입은 구체(concrete)입니다. 만약 타입이 추상 타입이라면, TypeTag은 그 타입을 해석할 수 없습니다. 이 부분이 WeakTypeTag가 필요한 곳입니다. 잠깐 TypeTag 예제를 수정해보도록 하겠습니다:

val list: List[Int] = List(1, 2)  
val result = Recognizer.recognize(list)  

저기 있는 Int가 보이시나요? String, Set[Double] 또는 MyCustomClass와 같이, 다른 구체 타입도 가질 수 있습니다. 그러나 추상 타입을 가지고 있다면, WeakTypeTag가 필요할 겁니다.

다음이 예제입니다. 추상 타입에 대한 참조를 필요로 하므로 단순히 추상 클래스 내 모든 것을 래핑할 것입니다.

import scala.reflect.runtime.universe._  
abstract class SomeClass[T] {

  object Recognizer {
    def recognize[T](x: T)(implicit tag: WeakTypeTag[T]): String =
      tag.tpe match {
        case TypeRef(utype, usymbol, args) =>
          List(utype, usymbol, args).mkString("\n")
      }
  }

  val list: List[T]
  val result = Recognizer.recognize(list)
  println(result)
}

new SomeClass[Int] { val list = List(1) }  
// prints:
//   scala.type
//   type List
//   List(T)

결과 타입은 List[T]입니다. WeakTypeTag이 아닌 TypeTag를 사용해왔다면, 컴파일러는 "List[T]에 대해 사용 가능한 TypeTag가 없다"라고 불평했을겁니다. 그러므로, WeakTypeTagTypeTag의 슈퍼셋의 한 종류로 볼 수 있습니다.

WeakTypeTag는 가능한 한 구체가 되려고 하기 때문에 일부 추상 타입에 사용 가능한 타입 태그가 존재한다면, WeakTypeTag는 해당 타입 태그를 사용할 것이므로 그것을 추상으로 남겨두는 대신 타입 구체로 만들 것입니다.

결론

끝내기 전에, 각 타입 태그가 사용 가능한 헬퍼를 사용해 명시적으로 초기화할 수도 있다는 점을 언급하고자 합니다:

import scala.reflect.classTag  
import scala.reflect.runtime.universe._

val ct = classTag[String]  
val tt = typeTag[List[Int]]  
val wtt = weakTypeTag[List[Int]]

val array = ct.newArray(3)  
array.update(2, "Third")

println(array.mkString(","))  
println(tt.tpe)  
println(wtt.equals(tt))

//  prints:
//    null,null,Third
//    List[Int]
//    true

그게 전부입니다. 세 가지 구조체, ClassTag, TypeTag 그리고 WeakTypeTag을 보았습니다. 이 구조체들은 여러분의 일상적인 스칼라 생활에서의 대부분의 타입 이레이저 문제를 해결할 것입니다. (원래 내부적으로는 reflection인) 태그 사용은 속도가 느려지고 생성된 코드가 훨씬 커질 수 있으므로 컴파일러를 "경우에 따라" 더 똑똑해지도록 만들기 위해 그리고 실용적인 이유로 라이브러리 전반에 암시적인 타입 태그를 추가하지 마십시오. 정말 필요한 경우에만 두도록 하십시오. 그리고 정말 필요한 경우, JVM의 타입 이레이저에 대한 강력한 무기를 제공할 겁니다.

언제든, sinisalouc@gmail.com로 메일을 보내거나 트위터로 연락을 주시기 바랍니다.