`into` 타입과 수식어
into 타입과 수식어 (The into Type and Modifier)
스칼라의 Conversion 클래스를 쓴 암시적 변환은 원래 언어 import가 필요했어요. Scala 3.9부터는 into라는 장치로 그 부담을 덜 수 있는데, 방식이 두 가지예요. 하나는 타입 생성자 into[T]로 쓰는 것, 다른 하나는 trait·클래스·불투명 타입 별칭에 soft 수식어로 붙이는 것이죠. 어떤 상황에 어느 쪽이 어울리는지 살펴볼게요.
본문
이 기능은 Scala 3.9.0부터 안정(stable)하게 사용할 수 있어요.
요약 (Summary)
Scala 3은 Scala-3의 Conversion 클래스를 사용해 암시적 변환을 언어 import 없이 허용하는 두 가지 대안 체계를 제공해요.
첫 번째 체계는 특별한 타입 into[T]를 사용하는 거예요. 이 타입은 그 타입으로의 변환이 허용된다는 마커 역할을 해요. 이런 타입은 보통 인자의 암시적 변환과 함께 동작하도록 설계된 메서드의 파라미터에 쓰여요. 이렇게 하면 암시적 변환을 어디에서 허용할지 세밀하게 제어할 수 있죠. 우리는 이 체계를 "타입 생성자로서의 into"라고 불러요.
두 번째 체계는 into를 trait, 클래스, 불투명 타입 별칭의 soft 수식어로 허용하는 거예요. 이 수식어로 선언된 타입 정의에 대해서는 그 타입으로의 변환이 허용됩니다. 이 체계는 형변환 대상 타입을 직접 제어할 수 있어야 해서, 변환 대상이 될 의도인 지정된 타입이 몇 개 있는 경우에 어울려요. 그렇다면 함수 시그니처를 다시 쓸 필요가 없으므로 Scala 2에서 Scala 3으로의 마이그레이션이 더 쉬워져요. 우리는 이 체계를 "수식어로서의 into"라고 부릅니다.
동기 (Motivation)
Scala 3의 scala.Conversion 클래스 암시적 변환은 언어 import를 요구해요.
import scala.language.implicitConversions
그것들을 암시적 변환으로 사용하는 모든 코드에서요. (변환을 명시적으로 호출하는 코드는 영향을 받지 않아요.) import가 없으면 현재 feature 경고가 나오고, 미래 Scala 3 버전에서는 오류가 될 예정이에요. 이 제한의 동기는 두 가지입니다:
- 숨겨진 암시적 변환이 있는 코드는 이해하기 어렵고, 감지되지 않는 정확성이나 성능 문제가 있을 수 있어요.
- 암시적 변환에 명시적 사용자 opt-in을 요구하면, opt-in이 없는 프로그램 부분에서 기대 타입 정보를 더 널리 전파해 타입 추론을 크게 개선할 수 있어요.
그런데 대체하기 매우 어려운 넓은 사용 사례가 하나 있어요. 바로 암시적 변환을 사용해 메서드 인자를 형식 파라미터 타입에 적응시키는 경우죠. 표준 라이브러리의 예를 보겠습니다:
scala> val xs = List(0, 1)
scala> val ys = Array(2, 3)
scala> xs ++ ys
val res0: List[Int] = List(0, 1, 2, 3)
xs ++ ys 입력 줄은 Array[Int]에서 IterableOnce[Int]로의 암시적 변환을 사용해요. 이 변환은 표준 라이브러리에서 암시적 def로 정의되어 있어요. 표준 라이브러리가 Scala 3 변환으로 다시 쓰이면, 사용 지점에 언어 import가 필요해지는데 이는 분명히 받아들일 수 없어요. 메서드 오버로딩이나 타입 클래스를 쓰면 암시적 변환의 필요를 피할 수 있지만, 종종 더 길고 복잡한 코드로 이어지고, 가변 인자(vararg) 파라미터에는 두 대안 모두 효과가 없어요.
첫 번째 체계: 타입 생성자로서의 into (First Scheme: into as a Type Constructor)
여기서 into 타입 생성자가 등장해요. 다음은 그것을 사용하는 List[A]의 ++ 메서드 시그니처예요:
def ++ (elems: into[IterableOnce[A]]): List[A]
elems 타입의 into 래퍼는, 실제 인자를 IterableOnce 값으로 변환하기 위해 암시적 변환을 적용할 수 있음을, 그리고 이것이 언어 import 없이 가능함을 의미해요.
into는 scala.Conversion 클래스의 동반 객체에 다음과 같이 정의됩니다:
opaque type into[T] >: T = T
into[T] 형태의 타입은 타입 체크 중에 특별히 취급되어요. 표현식의 기대 타입이 into[T]라면, 언어 import 없이 그 타입으로의 암시적 변환이 삽입될 수 있어요.
참고: 다른 타입과 달리
into는 소문자로 시작해요. 이는into가 컴파일러에 의해 특별히 취급된다는 사실을 강조하기 위한 것으로,into를 일반 타입보다는 키워드처럼 보이게 하죠.
예시 1
given Conversion[Array[Int], IterableOnce[Int]] = wrapIntArray
val xs: List[Int] = List(1)
val ys: Array[Int] = Array(2, 3)
xs ++ ys
이 코드는 xs ++ ys의 ys 인자에 주어진 변환을 삽입해요. ++의 형식 파라미터가 into[IterableOnce] 타입인데, 이것이 곧 ys의 기대 타입이므로 feature 경고 없이 타입 체크를 통과합니다.
예시 2
간단한 표현식 AST 타입을 생각해 봅시다:
enum Expr:
case Neg(e: Expr)
case Add(e1: Expr, e2: Expr)
case Const(n: Int)
import Expr.*
Add(1, Neg(2))처럼 명시적인 Const 래핑 없이 Expr 트리를 만들고 싶다고 해 볼게요. 보통 이는 Int에서 Const로의 암시적 변환으로 이루어져요:
given Conversion[Int, Const] = Const(_)
보통은 Expr 트리를 만드는 모든 소스 모듈에서 언어 import가 필요해요. Neg와 Add를 into 파라미터로 선언하면 사용자 코드에서 그 요구를 피할 수 있죠:
enum Expr:
case Neg(e: into[Expr])
case Add(e1: into[Expr], e2: into[Expr])
case Const(n: Int)
이렇게 하면 트리를 만들 때는 Int에서 Const로의 변환이 허용되지만, 다른 곳에서는 허용되지 않아요.
함수 결과에서의 into (into in Function Results)
into는 기대 타입으로 나타나는 어디서나 변환을 허용해요. 함수 인자의 결과에도 포함되죠. 예를 들어 List[A]의 flatMap 메서드의 새 제안 시그니처를 봅시다:
def flatMap[B](f: A => into[IterableOnce[B]]): List[B]
이것은 A에 적용했을 때 IterableOnce[B]로 변환 가능한 결과를 주는 모든 실제 인자 f를 받아들여요. 그래서 다음은 잘 동작합니다:
scala> val xs = List(1, 2, 3)
scala> xs.flatMap(x => x.toString * x)
val res2: List[Char] = List(1, 2, 2, 3, 3, 3)
여기서 String에서 Iterable[Char]로의 변환이, flatMap의 함수 인자를 xs의 요소에 적용한 결과에 적용돼요.
가변 인자 (Vararg arguments)
가변 인자 파라미터에 적용하면, into는 각 인자 값에 대해 개별적으로 변환을 허용해요. 예를 들어 IterableOnce[Char] 인자를 가변 개수만큼 이어붙이면서, IterableOnce[Char]로의 암시적 변환도 허용하는 concatAll 메서드를 생각해 봅시다:
def concatAll(xss: into[IterableOnce[Char]]*): List[Char] =
xss.foldRight(Nil)(_ ++: _)
여기서 다음 호출은
concatAll(List('a'), "bc", Array('d', 'e'))
두 가지 서로 다른 암시적 변환을 적용해요. String에서 Iterable[Char]로의 변환이 두 번째 인자에, Array[Char]에서 Iterable[Char]로의 변환이 세 번째 인자에 적용되죠.
into 풀기 (Unwrapping into)
into[T]는 opaque 타입이므로 그 런타임 표현은 그냥 T예요. 컴파일 타임에는 into[T]가 T의 알려진 상위 타입이에요. 그래서 t: T라면
val x: into[T] = t
는 타입 체크를 통과하지만,
val y: T = x // error
는 타입이 잘못됐어요. object Conversion에도 정의된 underlying 확장 메서드를 쓰면 하부 타입 T를 되찾을 수 있어요:
import Conversion.underlying
val y: T = x.underlying // ok
하지만 다음 섹션에서 보여주듯 파라미터에 대해서는 .underlying 풀기가 필요 없는데, 이게 가장 흔한 사용 사례예요. 그래서 명시적 풀기는 상당히 드물어야 해요.
메서드 본문 파라미터에서 into 떨어뜨리기 (Dropping into for Parameters in Method Bodies)
into 래퍼의 전형적인 사용 사례는 파라미터예요. 여기서 그것들은 해당 인자가 형식 파라미터 타입으로 변환될 수 있음을 지정해요. 반면 메서드 안에서는, 변환이 이미 감싸는 메서드가 호출될 때 일어났으므로 파라미터 타입이 하부 타입이라고 가정할 수 있어요. 이것은 메서드 본문에서 보이는 파라미터의 지역 타입에서 into 래퍼를 지우는 타입 시스템에 반영됩니다. 예를 보겠습니다:
def ++ (elems: into[IterableOnce[A]]): List[A] =
val buf = ListBuffer[A]()
for elem <- elems.iterator do // no `.underlying` needed here
buf += elems
buf.toList
++ 메서드 안에서 elems 파라미터는 into[IterableOnce[A]]가 아니라 IterableOnce[A] 타입이에요. 그래서 elems.iterator라고 쓰면 IterableOnce 클래스의 iterator 메서드에 바로 접근할 수 있습니다.
구체적으로, 공변 또는 불변 위치에 나타나는 파라미터 타입의 지역 타입에서 모든 into 래퍼를 지워요. 반변(contravariant) into 래퍼는 유지되는데, 이것들이 전형적으로 함수 인자의 파라미터에 있기 때문이에요.
타입 별칭에서의 into 생성자 (Into Constructors in Type Aliases)
into는 일반 타입 생성자이므로 타입 별칭과 타입 파라미터를 포함한 어디서든 쓸 수 있어요. 예를 들어 Scala 표준 라이브러리에서 다음처럼 정의할 수 있죠:
type ToIterator[T] = into[IterableOnce[T]]
그리고 나서 ++, flatMap 같은 함수가 파라미터 타입에서 이 별칭을 쓸 수 있어요. 효과는 into를 명시적으로 쓴 것과 같습니다.
두 번째 체계: 수식어로서의 into (Second Scheme: into as a Modifier)
지금까지 논의한 into 체계는 명시성과 편의성 사이에서 좋은 균형을 잡아요. 하지만 Scala 2 implicits에서 마이그레이션하려면 상당한 변경이 필요해요. 인자에 변환을 허용하도록 아마 많은 함수 시그니처를 바꿔야 하기 때문이죠. 이는 결국 Scala 3 implicits로의 마이그레이션을 막을 수도 있어요.
마이그레이션을 돕기 위해, 우리는 암시적 변환의 대상 타입을 지정하는 대안적인 방법도 소개해요. into를 클래스, trait, 불투명 타입 별칭의 soft 수식어로 허용하는 거죠. 타입 정의가 into로 선언되면, 그 타입으로의 암시적 변환은 언어 import가 필요 없어요.
예를 들어 Laminar 프레임워크는 사용자 정의 메서드의 파라미터 타입으로 흔히 쓰이고, 그쪽으로의 암시적 변환을 지원해야 하는 trait Modifier를 정의해요. Modifier는 Laminar 프레임워크 함수와 Laminar을 쓰는 애플리케이션 레벨 함수 둘 다에서 파라미터 타입으로 흔히 쓰여요.
Modifier를 into trait로 만들기만 하면 Modifier로의 암시적 변환을 지원할 수 있어요:
into trait Modifier ...
이것은 Modifier 결과를 가진 암시적 Conversion 인스턴스가 언어 import 없이 삽입될 수 있음을 의미해요.
간단한 예를 보겠습니다:
trait Modifier
given Conversion[Option[Node], Modifier] = ...
given Conversion[Seq[Node], Modifier] = ...
def f(x: Source, m: Modifier) = ...
f(source, Some(node)) // inserts conversion
수식어로서의 into 체계는, 같은 코드베이스에 정의된 암시적 변환의 대상이 될 의도인 특정 타입이 소수로 있는 코드베이스에서 편리해요. Laminar의 Modifier가 전형적인 예시죠. 하지만 into 타입의 수를 너무 크게 만들면 쉽게 남용될 수 있어요. into로 선언된 타입의 수를 절대 최소로 제한해야 합니다. 특히 누군가 나중에 암시적 변환을 추가하고 싶을지도 모른다는 이유만으로 타입을 into로 만들지 마세요.
세부 사항: 변환 대상 타입 (Details: Conversion target types)
앞선 설명을 더 정확히 하자면: 대상 타입이 유효한 변환 대상 타입(valid conversion target type)이라면, implicitConversions 언어 import 없이 암시적 변환이 허용돼요. 유효한 변환 대상 타입은 다음 중 하나예요:
into[T]형태의 타입.into수식어로 선언된 클래스, trait, 불투명 타입 별칭C에 대한 참조p.C. 참조 뒤에 타입 인자가 올 수 있어요.- 유효한 변환 대상 타입의 타입 별칭.
- 유효한 변환 대상 타입으로 축약되는 매치 타입(match type).
T가 유효한 변환 대상 타입인 어노테이션된 타입T @ann.T가 유효한 변환 대상 타입인 리파인드 타입(refined type)T {...}.- 두 유효한 변환 대상 타입
T와U의 유니온T | U. - 두 유효한 변환 대상 타입
T와U의 인터섹션T & U. - 유효한 변환 대상 타입으로 명시적으로 인스턴스화된 타입 파라미터의 인스턴스.
완전히 인스턴스화되지 않은 타입 파라미터는 유효한 변환 대상 타입으로 치지 않아요. 예를 들어 다음을 봅시다:
trait Token
class Keyword(str: String)
given Conversion[String, Keyword] = KeyWord(_)
List[into[Keyword]]("if", "then", "else")
이것은 리스트 요소의 대상 타입이 List.apply 메서드의 타입 파라미터인데, 그것이 into[Keyword]로 명시적으로 인스턴스화되었기 때문에 타입 체크를 통과해요. 반면 이 예시를 이렇게 이어가면 오류가 나요:
val ifKW: into[Keyword] = "if"
val ys: List[into[Keyword]] = List(ifKW, "then", "else")
여기서 List(...) 인자를 검사할 때 List.apply의 타입 변수가 명시적으로 인스턴스화되지 않아요(그냥 대상 타입 into[Keyword]로 상한이 제한될 뿐이죠). 이것은 두 번째와 세 번째 인자의 암시적 변환을 허용하기에 충분하지 않아요.
into 클래스나 trait의 서브클래스는 유효한 변환 대상 타입으로 치지 않아요. 예를 들어:
into trait T
class C(x: Int) extends T
given Conversion[Int, C] = C(_)
def f(x: T) = ()
def g(x: C) = ()
f(1) // ok
g(1) // error
f("abc") 호출은 f의 파라미터 타입 T가 into이므로 타입 체크를 통과해요. 하지만 g("abc") 호출은 g의 파라미터 타입 C가 into가 아니므로 타입 체크를 통과하지 못해요. C가 into인 trait T를 확장한다는 것은 중요하지 않아요.
왜 두 체계인가 (Why Two Different Schemes?)
두 체계 대신 하나만으로 할 수 있을까요? 실제로는 어려워요.
먼저 타입 생성자로서의 into를 쓰는 Expr 예시를 봅시다. 이것을 수식어로서의 into로 다시 쓸 수 있을까요? 그러려면 Expr enum 전체에 into를 추가해야 해요. Const에만 추가하는 걸로는 부족해요. Add와 Neg가 Const 인자가 아니라 Expr 인자를 받기 때문이죠.
하지만 Expr enum을 변경할 권한이 항상 있는 건 아니에요. 예를 들어 Expr이 암시적 변환 없는 하위 레벨 라이브러리에 정의되어 있지만, 나중에 어떤 상위 레벨 라이브러리나 애플리케이션에서 Const 래퍼를 생략해서 Expr 생성을 편리하게 만들고 싶을 수 있죠. into 생성자로는 쉽습니다. into[Expr] 파라미터를 받으면서 Expr 트리를 만드는 암시적 변환과 파사드 메서드를 정의하면 되니까요. into 수식어로는 같은 것을 이룰 방법이 없어요.
아마 더 중요한 반론은, Expr에 into 수식어를 추가할 수 있다고 해도 그렇게 하는 게 나쁜 스타일이라는 점이에요! 우리는 Add와 Neg 생성자를 사용해 Expr 트리를 만드는 아주 구체적인 경우에만 암시적 변환을 허용하고 싶어요. 애플리케이션에는 Expr 트리를 인자로 받는 다른 메서드가 많을 수 있어요. 예를 들어 그것들을 분석하거나 평가하는 메서드 말이죠. 아마 우리는 이 모든 다른 메서드의 인자에 암시적 변환을 허용하고 싶지 않을 거예요. into 수식어는 좋은 사용 사례와 문제 있는 사용 사례를 구분하기에 너무 비특이적이에요.
반면 수식어로서의 into가 실제적인 선택인 상황도 있어요. 이를 보려면 Laminar의 Modifier 사용 사례를 다시 생각해 봅시다. 모든 Modifier 파라미터를 into 생성자로 감싸서 into 수식어를 피할 수도 있어요. 그런데 그건 단일 into 수식어 하나를 추가하는 것보다 훨씬 많은 작업이에요. 더 나쁜 것은, Modifier 파라미터를 받는 함수가 Laminar 프레임워크 코드와 그것을 쓰는 많은 애플리케이션 양쪽에 있다는 점이에요. 프레임워크와 애플리케이션이 보조를 맞춰 업그레이드되어야 하죠. Laminar가 Scala 3 implicits로 업그레이드할 때 모든 애플리케이션을 다시 써야 한다면 그런 마이그레이션은 매우 번거로울 거예요.
타입 별칭을 활용해 그 노력을 완화하려 시도할 수도 있어요. 예를 들어 Scala 3 변환을 쓰는 가상의 미래 Laminar는 trait Modifier를 ModifierTrait로 개명하고 별칭을 정의할 수 있어요:
type Modifier = into[ModifierTrait]
그러면 애플리케이션의 소스 코드는 바뀔 필요가 없어요(이 애플리케이션들이 Modifier를 직접 확장하는 클래스를 정의하지 않는 한). 하지만 그 미래 Laminar는 원래 Modifier trait의 이름이 바뀌었으므로 현재 것과 바이너리 호환되지 않아요. 요약하면, Laminar를 Scala 3 변환으로 업그레이드하면 소스 호환성과 바이너리 호환성 중 하나는 유지할 수 있어도 둘 다 동시에 유지할 수는 없어요.
문법 변화 (Syntax Changes)
LocalModifier ::= ... | ‘into’
into는 soft 수식어예요. 클래스, trait, 불투명 타입 별칭에만 허용됩니다.