유니언 타입과 합 타입의 차이, 그리고 Julia와 Rust에서 각각의 장단점을 살펴봅니다.
작성일 2021-10-06
유니언 타입과 합 타입은 수십 년 전부터 존재해 온 프로그래밍 언어 개념이지만, 최근 몇 년 사이 더 인기를 얻고 있는 듯합니다. 두 개념은 밀접하게 연관되어 있지만, 미묘한 차이가 각자의 강점에 영향을 줍니다. 이 글에서는 이 개념들을 설명하고 둘의 장단점을 나열합니다.
유니언 타입, 합 타입, 곱 타입은 모두 _대수적 데이터 타입_입니다. 아주 복잡하게 들리지만, 기본 개념은 사실 정말 단순합니다.
익숙한 곳에서 시작해 봅시다. 일반적인 구조체입니다. 제가 일하는 곳에서 사용하는 데이터베이스에는 다음과 같은 식별자로 알려진 "사건"이 있습니다.
struct CaseID_V2 {
year: u16,
number: u32
}
이 정의는 새로운 타입 CaseID_V2를 만듭니다. 구조체는 AND 연산자처럼 생각할 수 있습니다. CaseID_V2는 u16 AND u32로 구성된 새 타입입니다.
그런데 _타입_이란 정확히 무엇일까요?
타입은 가능한 값들의 집합으로 생각할 수 있습니다. 여기서는 u16을 보겠습니다.
u 16 = \left{\right. 0 x 0000 , 0 x 0001 , 0 x 0002...0 x f f f f \left.\right}
CaseID_V2에는 어떤 값들이 있을까요? CaseID_V2가 u16과 u32라면, 가능한 CaseID_V2의 집합은 단순히 두 타입의 데카르트 곱입니다. 즉, 두 값의 가능한 모든 조합이며 로 표기합니다.
짜잔! 그래서 구조체를 곱 타입이라고 부릅니다. 이게 전부입니다.
하지만 때로는 한 필드 AND 다른 필드가 아니라, 한 필드 OR 다른 필드로 구성된 새 타입이 필요합니다. 제가 일하는 곳의 같은 데이터베이스에서는 어떤 이유에서인지 2021년에 실제로 CaseID가 바뀌었습니다. 그래서 앞선 예제에 _V2 접미사가 붙었던 것입니다. 이전 정의는 다음과 같았습니다.
struct CaseID_V1 {
numbers: u32,
letters: u32 // base36으로 인코딩됨
}
이제 사건 ID를 포함하는 모든 데이터 타입은 CaseID_V1 또는 CaseID_V2 중 하나를 담을 수 있어야 합니다. 이런 양자택일 타입을 _유니언 타입_이라고 합니다.
의사 코드로는 다음과 같이 보일 수 있습니다.
union type CaseID {
CaseID_V1,
CaseID_V2
}
원한다면 그것을 구조체에 넣을 수도 있습니다.
struct Case {
id: CaseID,
creation: Date,
[ 등등 ]
}
왜 유니언 타입이라고 부를까요? 구조체를 곱 타입이라고 부르는 이유와 비슷합니다. 새 유니언 타입의 가능한 값은 구성원의 _합집합_입니다.
값이 CaseID_V1 또는 CaseID_V2이므로, 가능한 값의 집합은 명백히 어느 한 집합에 속하는 모든 값이며, 동등하게 말하면 두 집합의 합집합입니다.
하지만 여기에는 난점이 있습니다. 다음과 같이 하면 어떨까요?
union type MyType {
bool,
bool
}
이는 MyType이 bool 또는… bool이라는 뜻인가요? 가능한 값은 몇 개일까요?
여전히 단지 \left{\right. f a l s e , t r u e \left.\right} 집합입니다! 다시 말해 MyType은 bool과 동등합니다. 혹은 아예 bool이라고 말할 수도 있습니다.
이 단순화는 꽤 멋집니다. 타입에 대한 불확실성을 유니언 타입으로 표현하고, 이에 대해 집합 연산을 수행할 수 있기 때문입니다. 예를 들어 이라는 유니언을 반환하는 함수 f와, 총 네 가지 가능한 타입에 대해 를 반환하는 함수 g가 있다고 합시다. 이제 f OR g 중 하나를 호출하면 가능한 반환 타입은 무엇일까요?
단순히 이며, 중복 제거되어 세 가지 타입만 남습니다.
한 타입이 다른 타입의 상위 집합인 두 타입을 유니언할 때도 비슷한 단순화가 일어납니다. 예를 들어 언어에 폭과 무관하게 "모든 부호 없는 정수"를 뜻하는 uint 타입이 있다고 합시다. 이 경우 입니다. 결국 uint 값의 집합은 u16 값의 집합을 포함하기 때문입니다.
프로그래밍할 때 때로는 그런 중복 제거를 원하지 않을 수 있습니다. 그레고리력의 연도(u16에 저장됨) 또는 히즈리력의 연도(마찬가지로 u16에 저장됨)를 담는 유니언 타입을 만들고 싶다고 합시다. 이 경우 두 u16은 같은 표현을 우연히 공유할 뿐 _서로 다른 것_이고 혼동되어서는 안 되므로, 이라는 유니언 타입으로 표현할 수 없습니다.
해결책은 아주 간단합니다. u16을 감싸고 프로그램이 데이터를 해석하는 방법을 알 수 있도록 "타입 태그" 역할을 하는 두 새 타입을 만듭니다. 다음과 같은 식입니다.
struct Year_Gregorian {
val: u16
}
struct Year_Hijri {
val: u16
}
union type Year {
Year_Gregorian,
Year_Hijri
}
이처럼 각 구성원에 태그가 붙은 유니언 타입을 _태그된 유니언_이라고 합니다. _합 타입_이라고도 합니다. 왜 합 타입이라고 부르는지는 이제 짐작할 수 있을 것입니다. Year 타입 값의 수는 구성원의 수를 정확히 합한 것과 같습니다. .
합 타입은 유니언의 모든 구성원을 구별할 수 있음을 100% 확신하고 싶을 때 정말 유용합니다.
Rust는 합 타입을 "열거형"이라고 부릅니다(약간 잘못된 명칭입니다). 꽤 복잡한 합 타입도 아주 쉽게 만들 수 있습니다.
enum ComplicatedEnum {
IsEmpty,
Color(u8, u8, u8),
Name { given: String, sur: String }
}
Rust 열거형의 흥미로운 특징 하나는 다음과 같습니다. IsEmpty, Color, Name이라는 세 일반 타입을 정의하는 대신, 이 세 "변형"은 ComplicatedEnum의 일부로서만 존재할 수 있고 독립적으로 존재할 수는 없습니다. 이는 어떤 값도 IsEmpty 타입을 가질 수 없음을 뜻합니다. ComplicatedEnum의 모든 값은 그저 ComplicatedEnum 타입입니다.
Rust에서 합 타입을 이렇게 "강제로 감싸는" 데 큰 이론적 이유가 있다고 생각하지는 않지만, Rust 합 타입의 실용적 사용에는 중요한 의미가 있습니다. 이 점은 조금 뒤에 다루겠습니다.
Julia에서 타입은 "값의 집합으로서의 타입"이라는 개념과 완벽하게 부합합니다.
julia> 5 isa Int # 5가 Int의 인스턴스인지 확인
true
julia> 5 isa Union{Int, String}
true
julia> 5 isa Integer # Integer는 Int의 상위 집합
true
julia> 5 isa Union{String, Set, Char}
false
julia> Union{Int, Integer, Char, UInt, Int} # 중복 제거
Union{Char, Integer}
요컨대 값 5는 Int, Union{Int, String}, Integer와 무한히 많은 다른 타입 모두에 속합니다.
Rust와의 또 다른 차이는 Julia가 동적 언어라는 점입니다. 간단히 말하면 정적 언어에서는 표현식(예: 코드)에 타입이 있지만, 타입은 최적화되어 사라지고 모든 것이 그저 이진 덩어리가 되므로 실행 시간에는 실제로 존재하지 않습니다. 동적 언어에서는 값이 실행 시간에 타입을 가지며, 컴파일러가 실행 전에 추론한 타입은 중요하지 않습니다. 실행 시간에 실제로 어떤 값이나 타입이 생성되는지에는 영향을 미치지 않습니다.
이는 컴파일러가 어떤 값 x를 Union{A, B, C} 타입으로 추론하더라도, 실행 시간에 x의 타입은 그저 A, B, 또는 C라는 뜻입니다. 유니언 타입은 실행 시간에 존재하지 않습니다. 프로그램 실행 시 무슨 일이 일어날지에 대한 컴파일러의 불확실성을 표현하는 데만 사용됩니다.
Julia와 Rust 타입의 차이 중 다수는 합 타입과 유니언 타입의 차이 자체보다는 Rust 합 타입의 "강제 감싸기"에서 비롯됩니다.
A를 받도록 되어 있는 API가 있다면, A 타입의 모든 값은 Union{A, B} 타입의 값이기도 하므로 호환성을 깨지 않고 언제든 Union{A, B}를 받도록 바꿀 수 있습니다.
마찬가지로 함수가 Union{A, B}를 반환한다면, 호환성을 깨지 않고 A만 반환하도록 바꿀 수 있습니다.
Rust에서는 이것이 작동하지 않습니다. Option<usize>를 받던 함수를 사용자 코드를 깨지 않고 usize를 받도록 바꿀 수 없으며, 이전에 Option<usize>를 반환하던 곳에서 usize를 반환할 수도 없습니다.
Rust에서는 합 타입의 변형이 항상 감싸여 있으므로 직접 접근할 수 없습니다. 이 때문에 매우 많은 상용구 코드가 생깁니다. 다양한 상황에서 이 타입들을 풀고 다시 감싸기 위해 존재하는 Option과 Result의 긴 메서드 목록을 확인해 보세요.
Julia의 시스템에서는 훨씬 쉽습니다. 애초에 감싸여 있지 않으므로 풀거나 다시 감쌀 필요가 없습니다. Union{Int, UInt}인 x에 1을 더하려면 어떻게 할까요? 일반적인 정수처럼 그냥 x + 1을 하면 됩니다.
더 좁은 유니언 타입을 반환하거나 더 넓은 유니언 타입을 받도록 바꾸는 것이 호환성을 깨지 않는 것처럼, 컴파일러가 그렇게 변경하는 것 역시 허용됩니다.
Union{A, B}를 반환하는 함수 f를 작성하고, 이를 기대하는 함수 g에 전달한다고 합시다. 그런데 어떤 코드에서는 인수 하나를 상수로 하여 f를 호출합니다. 그러면 컴파일러는 그 상수 인수가 f의 반환 타입을 좁히는지 확인합니다. 상수 폴딩된 인수로 인해 f가 A를 반환한다고 보장된다고 합시다. 그렇다면 컴파일러는 g가 Union{A, B}가 아니라 A를 받는다는 것을 알게 됩니다. 따라서 이제 g를 추가로 최적화할 수 있습니다. 예를 들어 입력이 B일 때 발생하는 모든 분기를 컴파일 단계에서 제거할 수 있습니다.
Julia의 유니언 타입은 구체 타입인 것처럼 사용할 수 있어서 상용구 코드가 적을 수 있지만, 그것은 위험한 함정이기도 합니다.
Union{Int, Nothing}을 반환하는 Julia 함수 findfirst와 Option<usize>를 반환하는 Rust의 iter.position을 생각해 봅시다. findfirst가 아무것도 반환하지 않을 수 있다는 점을 잊고 그 경우를 처리하지 않아 버그를 만들기 쉽습니다. 그러나 Option<usize>와 usize는 호환되지 않는 타입이고 합 타입을 반드시 풀어야 하므로, Option<usize>를 usize로 착각하는 것은 불가능합니다.
유니언 타입으로 가능한 복잡한 집합 연산은 단순히 코드를 작성하려 할 때도 꽤 성가실 수 있습니다. 예를 들어 f가 타입 T를 반환하는 함수라고 합시다. 다음 Rust 코드의 반환 타입은 무엇일까요?
vec![f()]
네, Vec<T>입니다. 그렇다면 다음 Julia 코드의 반환 타입은 무엇일까요?
[f()]
당연히 Vector{T}겠죠? 아닙니다. 꼭 그렇지는 않습니다.
julia> f() = rand(Bool) ? 1 : nothing;
julia> g() = [f()];
julia> only(Core.Compiler.return_types(g, ()))
Union{Vector{Int64}, Vector{Nothing}}
유니언의 벡터가 아니라 벡터의 유니언입니다. 곰곰이 생각해 보면 반드시 그래야 하지만, 유니언 타입이 갑자기 영리한 일을 하며 발밑의 양탄자를 잡아당길 수 있는 사례 중 하나일 뿐입니다.
위에서 언급한, 유니언 타입의 자동 축소로 가능해지는 Julia 컴파일러 최적화는 멋집니다. 하지만 예를 들어 10개 변형으로 구성된 유니언이 있다면 어떨까요? Julia와 Rust처럼 모든 입력 타입에 특화된 함수를 컴파일하는 언어("단일형태화")에서는 이로 인해 조합 폭발이 일어나 막대한 컴파일 시간과 비대해진 코드로 이어질 수 있습니다. 실제로 Julia에서는 값이 4개를 초과하는 구성원을 가진 유니언이라고 추론하면, 문제가 너무 심각해져 컴파일러가 그냥 포기하고 실행 시간에 타입을 검사하는 코드를 생성합니다.
이 경우 어떤 변형을 가지고 있는지 if/else 문으로 간단히 검사하는 편이 영리한 컴파일러 기법보다 훨씬 효율적입니다. 아니면 if/else 문보다도 더 나은 방법이 있습니다.
바로 Rust의 합 타입은 이런 영리한 타입 연산을 수행하지 않기 때문에, 사용자는 A, B, C 변형을 가진 합 타입이 같은 변형을 가진 동일한 타입으로 유지된다고 확신할 수 있습니다.
이것은 _완전한 패턴 매칭_을 가능하게 합니다. 즉, 어떤 예외 경우라도 빠뜨리면 컴파일 시점에 감지하는 패턴 매칭입니다. Rust를 5분 이상 사용해 봤다면 이것이 식빵을 썬 이래 최고의 것이라는 사실을 이미 알 것입니다. 아니라면, 여러분이 가장 좋아하는 언어에 이것이 있다면 얼마나 좋을지 알 수 있도록 꼭 한번 써 보기를 강력히 권합니다.
유니언 타입과 합 타입 모두 장점이 있습니다. 아주 적절하게도 유니언 타입은 Julia의 강점을 살립니다. 표현력 있는(상용구 코드가 적은), 제네릭하고 빠른 코드를 가능하게 합니다. 반면 Rust의 합 타입은 예측 가능한 타입의 코드와 예외 경우를 강제로 검사함으로써 훨씬 더 안전한 코드를 가능하게 합니다.
유니언 타입과 합 타입 간의 이러한 절충이 본질적이라고는 생각하지 않습니다. 둘 다 가질 수 있을지도 모른다고 생각하지만, 그런 시스템이 어떤 모습일지는 아직 확신하지 못했습니다.
바라건대, 그건 다음 기회의 블로그 글, 혹은 Julia 패키지가 될 것입니다!