이름 있는 튜플
이름 있는 튜플 (Named Tuples)
튜플의 각 요소에 이름을 붙이면, 위치만으로 헷갈리던 필드를 p.age처럼 이름으로 접근할 수 있어요. Scala 3.7부터 요소에 이름을 줄 수 있는 '이름 있는 튜플(named tuple)'이 생겼는데, 내부적으로는 일반 튜플의 편리한 문법일 뿐이라 런타임 표현도 같아요. 사용법과 제약을 차근차근 볼게요.
본문
Scala 3.7부터 튜플의 요소에 이름을 붙일 수 있어요. 예시:
type Person = (name: String, age: Int)
val Bob: Person = (name = "Bob", age = 33)
Bob match
case (name, age) =>
println(s"$name is $age years old")
val persons: List[Person] = ...
val minors = persons.filter: p =>
p.age < 18
튜플에서의 이름 있는 바인딩은 함수 파라미터와 인자와 비슷해요. 요소 타입에는 name: Type을, 요소 값에는 name = value를 써요. 튜플에서 이름 있는 요소와 이름 없는 요소를 섞는 것은 불법이고, 두 요소에 같은 이름을 쓰는 것도 불법이에요.
이름 있는 튜플의 필드는 위의 p.age < 18 줄처럼 이름으로 선택할 수 있어요.
적합성과 변환 가능성 (Conformance and Convertibility)
이름 있는 튜플에서 이름의 순서는 중요해요. 예를 들어 위의 Person 타입과 (age: Int, name: String) 타입은 서로 다르고 호환되지 않는 타입이에요.
이름 있는 튜플 타입의 값은 일반 튜플로도 정의할 수 있어요. 예를 들어:
val Laura: Person = ("Laura", 25)
def register(person: Person) = ...
register(person = ("Silvain", 16))
register(("Silvain", 16))
이것은 일반 튜플 (T_1, ..., T_n)이 같은 요소 타입을 가진 이름 있는 튜플 (N_1 = T_1, ..., N_n = T_n)의 부분타입으로 취급되기 때문이에요.
반대 방향으로는 toTuple 메서드로 이름 있는 튜플을 이름 없는 튜플로 변환할 수 있어요. 예시:
val x: (String, Int) = Bob.toTuple // ok
toTuple은 NamedTuple 객체에 확장 메서드로 정의되어 있어요. 주어진 튜플을 변경 없이 돌려주고 단순히 이름들을 "잊어버려요".
컴파일러가 이름 있는 튜플을 만났는데 기대 타입이 일반 튜플이면 .toTuple 선택을 암시적으로 삽입해요. 그래서 다음도 잘 동작합니다:
val x: (String, Int) = Bob // works, expanded to Bob.toTuple
한 방향의 부분타입과 다른 방향의 자동 .toTuple 변환 사이의 차이는 상대적으로 작아요. 주요 차이는 .toTuple 변환이 타입 생성자 안에서는 동작하지 않는다는 점이에요. 그래서 다음은 괜찮습니다:
val names = List("Laura", "Silvain")
val ages = List(25, 16)
val persons: List[Person] = names.zip(ages)
하지만 다음은 불법이에요.
val persons: List[Person] = List(Bob, Laura)
val pairs: List[(String, Int)] = persons // error
이것을 표현하려면 명시적 _.toTuple 선택이 필요해요:
val pairs: List[(String, Int)] = persons.map(_.toTuple)
이름 있는 튜플의 적합성 규칙은 이름 있는 파라미터의 규칙과 유사하다는 점을 기억하세요. 이름 있는 파라미터 리스트에 파라미터를 위치로 지정할 수 있어요.
def f(param: Int) = ...
f(param = 1) // OK
f(2) // Also OK
하지만 이름을 써서 이름 없는 파라미터에 인자를 전달할 수는 없어요:
val f: Int => T
f(2) // OK
f(param = 2) // Not OK
튜플의 규칙도 유사해요. 이름 없는 튜플은 이름 있는 튜플 타입에 적합하지만, 그 반대는 변환이 필요해요.
패턴 매칭 (Pattern Matching)
이름 있는 튜플을 패턴 매칭할 때, 패턴은 이름이 있어도 되고 없어도 돼요. 패턴에 이름이 있으면 튜플 이름의 일부만 언급하면 되고, 그 이름들은 어떤 순서로든 올 수 있어요. 그래서 다음은 모두 괜찮습니다:
Bob match
case (name, age) => ...
Bob match
case (name = x, age = y) => ...
Bob match
case (age = x) => ...
Bob match
case (age = x, name = y) => ...
일반적인 이름 있는 필드 패턴 매칭 (Pattern Matching with Named Fields in General)
우리는 이름 있는 패턴을 이름 있는 튜플뿐 아니라 케이스 클래스에도 허용해요. 예를 들어:
city match
case c @ City(name = "London") => println(c.population)
case City(name = n, zip = 1026, population = pop) => println(pop)
이름 있는 생성자 패턴은 이름 있는 튜플 패턴과 유사해요. 두 경우 모두:
- 모든 이름은 선택자의 어떤 필드 이름과 매치해야 한다.
- 이름들은 어떤 순서로든 올 수 있다.
- 선택자의 모든 필드가 매치될 필요는 없다.
이름 있는 패턴은 확장 가능한 패턴 매칭과 호환돼요. unapply 결과가 이름 있는 튜플일 수 있기 때문이죠.
확장 (Expansion)
이름 있는 튜플은 본질적으로 일반 튜플의 편리한 문법일 뿐이에요. 내부 표현에서 이름 있는 튜플 타입은 컴파일 타임에 두 튜플의 쌍으로 표현돼요. 한 튜플은 이름들을 리터럴 상수 문자열 타입으로, 다른 튜플은 요소 타입들을 담아요. 이름 있는 튜플의 런타임 표현은 요소 값들로만 구성되고 이름은 잊혀져요. 이것은 NamedTuple을 패키지 scala에 다음처럼 opaque 타입으로 선언함으로써 이루어집니다:
opaque type NamedTuple[N <: Tuple, +V <: Tuple] >: V = V
예를 들어 Person 타입은 다음 타입으로 표현돼요.
NamedTuple[("name", "age"), (String, Int)]
NamedTuple은 두 번째 값 파라미터의 opaque 타입 별칭이에요. 첫 번째 파라미터는 요소의 이름을 결정하는 문자열 상수 타입이죠. 타입이 값 부분의 별칭일 뿐이므로 이름은 런타임에 이레이즈되고, 이름 있는 튜플과 일반 튜플은 같은 표현을 가져요.
NamedTuple[N, V] 타입은 공개적으로 그 값 파라미터 V의 상위 타입(부분타입은 아니에요)으로 알려져 있어요. 즉 일반 튜플은 이름 있는 튜플에 할당될 수 있지만 그 반대는 안 된다는 뜻이에요.
NamedTuple 객체에는 Tuple의 같은 함수들을 반영하는 이름 있는 튜플용 확장 메서드가 여럿 있어요. 예를 들면 apply, head, tail, take, drop, ++, map, zip 같은 것들이죠. Tuple과 유사하게 NamedTuple 객체에는 이런 확장 메서드들의 결과를 설명하는 Elem, Head, Concat 같은 타입들도 포함되어 있어요.
이름 있는 튜플의 NamedTuple 인스턴스로의 번역은 명세에 고정되어 있어서 프로그래머에게 알려져 있어요. 이는 다음을 의미해요:
- 모든 튜플 연산이 이름 있는 튜플에서도 "즉시(out of the box)" 동작한다.
- 매크로 라이브러리가 이 확장에 의존할 수 있다.
계산된 필드 이름 (Computed Field Names)
Selectable trait은 이제 이름 있는 튜플로 인스턴스화될 수 있는 Fields 타입 멤버를 가져요.
trait Selectable:
type Fields <: NamedTuple.AnyNamedTuple
Fields가 Selectable의 서브클래스에서 어떤 이름 있는 튜플 타입으로 인스턴스화되면, 사용 가능한 필드와 그 타입이 그 타입에 의해 정의돼요. 어떤 클래스 C(이 Selectable을 구현)에서 n: T가 Fields 타입의 요소이고, c: C이며, n이 다른 방식으로는 c에 대한 선택의 이름으로써 합법적이지 않다고 가정해 봅시다. 그러면 c.n은 합법적인 선택이고, c.selectDynamic("n").asInstanceOf[T]로 확장돼요.
C에서의 selectDynamic 구현의 임무는 그것의 계산된 결과가 예측된 타입 T에 적합함을 보장하는 거예요.
예시로, 다음처럼 정의된 쿼리 타입 Q[T]가 있다고 가정해 봅시다:
trait Q[T] extends Selectable:
type Fields = NamedTuple.Map[NamedTuple.From[T], Q]
def selectDynamic(fieldName: String) = ...
사용자 도메인에서:
case class City(zipCode: Int, name: String, population: Int)
val city: Q[City]
그러면
city.zipCode
은 타입 Q[Int]를 가지고
city.selectDynamic("zipCode").asInstanceOf[Q[Int]]
로 확장돼요.
NamedTuple.From 타입 (The NamedTuple.From Type)
NamedTuple 객체에는 타입 정의가 포함되어 있어요.
type From[T] <: AnyNamedTuple
From은 컴파일러에 의해 특별히 취급돼요. NamedTuple.From이 케이스 클래스의 인스턴스인 인자 타입에 적용되면, 타입은 그 케이스 클래스의 모든 필드로 이루어진 이름 있는 튜플로 확장돼요. 여기서 필드는 첫 번째 파라미터 섹션의 요소들을 의미해요. 예를 들어 다음을 가정하면
case class City(zip: Int, name: String, population: Int)
NamedTuple.From[City]는 이름 있는 튜플
(zip: Int, name: String, population: Int)
이에요. 이것은 케이스 클래스로 확장되는 enum case, 케이스 클래스를 상한으로 가지는 추상 타입, 케이스 클래스로 확장되는 별칭 타입, 케이스 클래스를 하부 타입으로 가지는 싱글턴 타입에서도 동작해요.(구현 측면에서 타입의 classSymbol이 케이스 클래스여야 합니다.)
From은 이름 있는 튜플에도 정의돼요. NT가 이름 있는 튜플 타입이라면 From[NT] = NT예요.
이름 있는 튜플의 연산 (Operations on Named Tuples)
이름 있는 튜플의 연산은 객체 scala.NamedTuple에 정의되어 있어요.
제약 (Restrictions)
다음 제약이 이름 있는 튜플과 이름 있는 패턴 인자에 적용돼요:
- 튜플 또는 생성자 패턴의 모든 요소가 이름을 가지거나, 모두 가지지 않아야 해요. 튜플에서 이름 있는 요소와 이름 없는 요소를 섞는 것은 불법이에요. 예를 들어 다음은 오류예요:
val illFormed1 = ("Bob", age = 33) // error
- 이름 있는 튜플 또는 생성자 패턴의 각 요소 이름은 유일해야 해요. 예를 들어 다음은 오류예요:
val illFormed2 = (name = "", age = 0, name = true) // error
- 이름 있는 튜플과 케이스 클래스는 이름 있는 패턴 또는 일반 패턴으로 매치될 수 있어요. 하지만 일반 튜플과 다른 선택자 타입은 일반 튜플 패턴으로만 매치될 수 있어요. 예를 들어 다음은 오류예요:
(tuple: Tuple) match
case (age = x) => // error
문법 변화 (Syntax Changes)
Scala의 문법은 이름 있는 튜플과 이름 있는 생성자 인자를 지원하도록 다음과 같이 확장돼요:
SimpleType ::= ...
| ‘(’ NameAndType {‘,’ NameAndType} ‘)’
NameAndType ::= id ':' Type
SimpleExpr ::= ...
| '(' NamedExprInParens {‘,’ NamedExprInParens} ')'
NamedExprInParens ::= id '=' ExprInParens
Patterns ::= Pattern {‘,’ Pattern}
| NamedPattern {‘,’ NamedPattern}
NamedPattern ::= id '=' Pattern
소스 비호환성 (Source Incompatibilities)
길이가 1인 이름 있는 튜플과 관련된 몇 가지 소스 비호환성이 있어요. 첫째, 이전에 할당(assignment)으로 분류되던 것이 이제 이름 있는 튜플로 해석될 수 있어요. 예시:
var age: Int
(age = 1)
이것은 이전에는 괄호 안의 할당이었고, 지금은 arity 1인 이름 있는 튜플이에요. 하지만 할당은 보통 괄호로 감싸지 않으므로 관용적인 Scala 코드는 아니에요.
둘째, 중위(infix) 연산자에 대한 이름 있는 인자였던 것이 이제 이름 있는 튜플로 해석될 수 있어요.
class C:
infix def f(age: Int)
val c: C
그러면
c f (age = 1)
은 이름 있는 파라미터를 전달하는 대신 두 번째 피연산자로 튜플을 만들게 돼요.