2.6. 합 타입 (A | B)
합 타입은 여러 기존 타입 중 하나의 값을 갖는 태그형 합집합입니다. enum 과 비슷하지만, 변형이 따로 명명되지 않고 기존 타입을 그대로 변형으로 쓴다는 차이가 있습니다.
2.6.1. 형태
다음은 합 타입의 형태를 보여 주는 예시입니다.
i32 | f32
T | none
A | B | C
i32 | str | bool
항을 구분할 때에는 구두점과 연산자 토큰 의 | 토큰을 사용합니다. 그리고 항의 자리에는 임의의 타입 표현식이 올 수 있는데, 단순 이름이나 경로, 제네릭, 또는 또 다른 합 타입까지 모두 가능합니다.
2.6.2. 의미
합 타입의 값은 항 중 하나에 속하며, 어느 항에 속하는지는 태그를 통해 추적됩니다.
let x: i32 | f32 = 42i32; // 태그가 i32 자리에 있음
let y: i32 | f32 = 3.14f32; // 태그가 f32 자리에 있음
2.6.3. 메모리 표현
기본 표현은 태그와 페이로드를 인라인으로 두는 형태입니다.
+------+----------------------+
| tag | payload (size of |
| | largest variant) |
+------+----------------------+
이 구조에서 태그는 어느 항이 활성인지 식별하는 역할을 합니다. 페이로드는 모든 항 중 가장 큰 항의 크기만큼 자리를 갖도록 잡히고, 정렬은 모든 항의 정렬 중 가장 큰 값에 맞춰집니다.
다만 이 표현은 niche 최적화 의 영향을 받기 때문에 태그 자리가 사라지는 경우도 있습니다.
값 재귀와 크기 한도
- 값으로 자기 자신을 담는 재귀 타입 (struct, enum, 튜플, 합, 배열, 제네릭 인스턴스를 거쳐도) 은 선언 위치의 에러입니다. 재귀에는 GC 참조 타입 (
List<T>등) 을 씁니다. - rok 의 구현 한도 (구현이 정함, 0.4.4): 펼친 scalar 슬롯 65536 개 또는 4 GiB 를 넘는 값 타입은 에러입니다.
정확한 배치는 구현이 정하지만 한 가지는 보장됩니다. 0 으로 채운 T | none 저장소 (GcBuf<T | none> 와 컬렉션 버퍼의 원소, 새로 할당된 GC 객체의 필드) 는 none 으로 읽힙니다.
정확한 비트 폭
태그의 비트 폭은 컴파일러가 결정하며, 항의 수에 따라 1 비트, 2 비트, 4 비트, 8 비트 등 다양한 값이 가능합니다. 사용자 코드에서 태그 비트에 직접 접근할 방법은 제공되지 않습니다.
2.6.4. 집합 의미론
중요
합 타입은 집합 연산의 의미론을 갖습니다.
정규화
합 타입은 항상 정규형으로 다룹니다.
- 중첩된 합은 펼칩니다.
(A | B) | C,A | (B | C),A | B | C는 같은 타입입니다. - 중복은 제거합니다.
A | A | B는A | B입니다. - 항의 순서는 의미가 없습니다.
A | B와B | A는 같은 타입입니다. - 항이 하나 남으면 그 타입 자체입니다.
A | A는A입니다.
타입 별칭을 펼친 결과도 다시 정규화합니다 (type N = i32 | f64; 이면 N | i32 는 i32 | f64). 제네릭 파라미터의 치환은 아래 "제네릭 합의 층" 을 따릅니다.
따라서 합 타입의 형태가 같으면 같은 타입이며, 사용자가 i32 | f32 와 f32 | i32 중 어느 쪽을 적었는지 신경 쓸 필요가 없습니다.
제네릭 합의 층
제네릭 항을 가진 합 T | X1 | ... | Xn (예: T | none, T | ParseErr) 은 의미상 자기 층 을 가집니다. X1 ... Xn 은 그 합에 함께 적힌, 제네릭이 아닌 항입니다. 제네릭 항은 다음 넷입니다.
- 타입 파라미터 (
T) Self- 연관 타입 (
Self::X,T::X,<T as Tr>::X) - 제네릭 타입 별칭의 파라미터
층 규칙은 제네릭 항이 실제 타입으로 바뀔 때 적용됩니다. 바뀐 타입이 함께 적힌 항과 겹치면 중첩 층, 겹치지 않으면 펼칩니다. 아래에서 T 는 제네릭 항 하나를 대표합니다.
층이 남는 조건. T 를 치환한 타입이 X1 ... Xn 가운데 하나라도 같은 항을 가지면, 치환된 T 는 하나의 항으로 남아 중첩 층 이 됩니다. 겹치지 않으면 펼쳐집니다. 펼쳐도 값을 구별하는 정보가 사라지지 않으므로, 결과는 평평한 합으로 보이고 그렇게 다룹니다.
| 제네릭 합 | T 치환 | 결과 |
|---|---|---|
T | none | i32 | str | i32 | str | none (겹침 없음 - 펼침) |
T | none | i32 | none | (i32 | none)? (두 층) |
T | ParseErr | i32 | ParseErr | (i32 | ParseErr) | ParseErr |
T | str | none | i32 | str | (i32 | str) | str | none (str 이 겹침) |
- 겹침은 어떤 항이든 셉니다.
none만이 아닙니다. - 두 파라미터만의 합
T | U는 둘 다 치환되므로, 겹쳐도 중첩되지 않고 펼쳐집니다. 두 값을 구별해야 하면enum을 씁니다. - 각 층은 따로 정규화됩니다. 그래서
A | B == B | A는 모든 층에서 성립합니다. - 소스에 구체 타입으로 적은 합은 언제나 펼칩니다.
(i32 | none) | none이라고 적으면i32 | none입니다. - 제네릭이 아닌 타입 별칭은 적힌 타입이므로 언제나 펼칩니다.
type Opt = i32 | none; type Flat = Opt | none;이면Flat은i32 | none입니다. - 제네릭 별칭의 파라미터는 제네릭 항이므로 위 규칙을 따릅니다 (아래 "중첩 타입 적기").
중첩 타입 적기. optional 은 후위 ? 로 적습니다 (2.1.5a). T? 는 Maybe<T> 와 같으므로 그 피연산자는 제네릭 항이고, 같은 층 규칙이 적용됩니다. 그 밖의 중첩 ((i32 | E) | E 등) 은 제네릭 별칭이나 Self::X 로 적습니다.
type Maybe<T> = T | none;
let r: (i32 | none)? = xs.get(0); // 두 층
let q: Maybe<i32 | none> = xs.get(0); // 같은 타입
let p: i32? = 1; // i32 | none - 겹침 없음
let f: (i32 | str)? = none; // i32 | str | none
값 넣기. 중첩 합에 값을 넣을 때, T 의 항 타입 값은 안쪽 층으로 들어갑니다. 바깥 층의 항 타입 값 (none 리터럴, ParseErr 값 등) 은 바깥 층의 값입니다. 안쪽 층에 바깥 항 타입 값을 넣으려면 먼저 T 타입 슬롯에 담아 넣습니다.
변환. 중첩 타입은 평평한 타입으로 암묵 변환되지 않습니다. 타입 에러입니다.
let v: i32 | none = xs.get(0); // ERROR - xs: List<i32 | none>
// mismatched types: `let v` expects `i32 | none`, found `(i32 | none)?`
연산. 패턴, is, ?., ??, ? 는 가장 바깥 층 에 작용합니다. 바깥 층의 다른 쪽은 안쪽 T 에 묶입니다. 바깥 층의 모든 항을 덮는 arm 뒤의 바인딩 패턴은 안쪽 층을 묶습니다.
진단 표기. 진단은 중첩 층을 괄호로 묶어 보여 줍니다. 바깥 층이 none 하나뿐이면 (i32 | none)? 로, 그 밖에는 (i32 | E) | E, (i32 | none) | str | none 처럼 적습니다. (i32 | none)? 는 같은 의미의 올바른 소스이기도 합니다.
struct ParseErr { msg: str; }
fn check<T>(x: T) -> T | ParseErr {
if bad(x) { return ParseErr { msg: "bad" }; } // 바깥 층의 ParseErr
x // T 의 값 - 안쪽 층
}
let a = check(1i32); // T = i32 - 겹치지 않음: i32 | ParseErr
let v: i32 | ParseErr = ParseErr { msg: "earlier" };
let r = check(v); // T = i32 | ParseErr - 겹침: (i32 | ParseErr) | ParseErr
match r {
e: ParseErr => print(f"check 실패: {e.msg}"), // 바깥 층 - check 가 만든 에러
inner => match inner { // inner: i32 | ParseErr
e: ParseErr => print(f"원래 값이 에러: {e.msg}"), // 안쪽 층 - v 에 들어 있던 에러
n: i32 => print(n),
},
}
연관 타입. 트레잇과 모든 impl 안에서 맨 이름 X 와 Self::X 는 둘 다 그 연관 타입을 가리킵니다 (2.8.3). 연관 타입은 제네릭 항이므로 같은 규칙이 적용됩니다. fn next(ref self) -> Self::Item? 는 Item 이 none 을 담으면 중첩되므로, i32 | none 원소를 내는 사용자 iterator 도 저장된 none 을 원소로 돕니다.
none 의 경우 (중첩 optional) 는 2.6.5 의 예를 참조합니다.
정규 순서
정규형의 항에는 정해진 순서가 있습니다.
none은 늘 마지막입니다.- 나머지 항은 구조적 키로 정렬합니다: 원시 타입은 이름, struct 나 enum 은 전체 경로, generic 인스턴스는
path<arg keys>. 중첩 합은 펼쳐지므로 (위 정규화) 합이 합의 항이 되는 일은 없습니다. generic 인자 안의 합은 정규 순서의 항들로 키를 만듭니다 (List<A | B>의 키는List<key(A)|key(B)>). "제네릭 합의 층" 으로 남은 안쪽 층은 하나의 항이며, 그 키는(+ 정규 순서의 항 키들을|로 이은 것 +)입니다. 순서는 적은 순서와는 무관합니다. - 순서와 그에 따른 tag 는 빌드 내부의 타입 번호에 의존하지 않으므로, 미리 빌드한 라이브러리와 그 사용자가 일치합니다 (9.4.8).
합 타입 값의 PartialOrd / Ord 와 min / max / clamp 는 이 순서를 따릅니다 (stdlib 2.5.7). 서로 다른 항의 값은 정규 순서로, 같은 항의 값은 그 항 타입의 Ord 로 비교합니다. 그래서 T | none 에서 none 은 어떤 T 값보다 큽니다.
부분 집합
- 합 타입 값은 그 항을 모두 포함하는 더 큰 합 타입 자리에 그대로 들어갑니다.
i32 | none값은i32 | str | none자리에 넣을 수 있습니다. 항 하나 (i32) 의 값도 같습니다. - 타입 패턴
v: A | B는 더 큰 합의 일부 항에 맞출 수 있습니다. 그 분기에서v는A | B입니다.
let v: i32 | str | f64 | none = ...;
match v {
n: i32 | f64 => use_num(n), // n: i32 | f64
s: str => use_str(s),
none => {},
}
제네릭 추론
제네릭 추론은 합을 집합으로 봅니다. T | none 을 i32 | str | none 에 맞추면, 남는 항을 모아 T = i32 | str 입니다.
fn unwrap_or<T>(x: T | none, d: T) -> T { ... }
let v: i32 | str | none = ...;
unwrap_or(v, 0i32); // T = i32 | str
2.6.5. none 과의 결합
진리값과 센티넬 리터럴 의 none 은 합 타입의 한 항으로 가장 자주 등장합니다.
T | none
이 형태는 값이 있을 수도 있고 없을 수도 있음을 표현하며, 다른 언어의 Option<T>, T?, Maybe T 에 해당하는 자리입니다.
여기에 niche 최적화가 결합되면 (예를 들어 포인터형 T 의 경우) 메모리 오버헤드 없이 null 을 none 으로 표현할 수 있습니다. 정확한 규칙은 niche 최적화 에서 정의합니다.
중첩 optional
2.6.4 의 "제네릭 합의 층" 규칙을 none 에 적용한 것입니다. 제네릭 T | none 의 T 가 이미 none 을 담으면 바깥 none 층이 남아 중첩 optional 이 됩니다 (Swift 의 T?? 와 같음). Maybe<T> = T | none 에 T = i32 | none 을 넣은 Maybe<i32 | none>, 곧 (i32 | none)? 는 두 층이며, none 패턴, is none, ?., ??, ? 는 바깥 층에 작용하고 none 이 아닌 쪽은 안쪽 T (i32 | none) 에 묶입니다.
그래서 List<i32 | none> 은 저장된 none 을 원소로 돌며, get / pop / Dict 조회는 "없음" 과 "저장된 none" 을 구별합니다.
let xs: List<i32 | none> = [1, none, 3];
for x in xs { print(x); } // 1, none, 3 - 저장된 none 도 원소
match xs.get(1) { // get: (i32 | none) | none - 두 층
none => print("범위 밖"), // 바깥 none: 원소 없음
v => match v { // v: i32 | none - 저장된 값
none => print("저장된 none"),
n: i32 => print(n),
},
}
let d: Dict<str, i32 | none> = {"a": none};
d.get("a") ?? 0i32; // none - 키는 있고 값이 none (?? 는 바깥 층만 벗김)
d.get("b") ?? 0i32; // 0 - 키 없음
2.6.6. any 와의 차이
any 슬롯 의 any 는 열린 슬롯이어서, 모든 타입이 가능하고 변형을 자유롭게 추가할 수 있습니다.
반면 합 타입 (A | B) 은 닫힌 슬롯이어서, 명시된 변형만 가능합니다. 그 대신 컴파일러가 모든 가능한 항을 알고 있기 때문에 패턴 매칭의 완전성 검사를 수행할 수 있습니다.
| 측면 | any | A | B |
|---|---|---|
| 가능한 타입 | 모든 타입 | 명시된 항만 |
| 디스패치 | 런타임 태그 검사 | 런타임 태그 검사 (단 항이 적어 분기 적음) |
| 완전성 검사 | 불가 | 가능 |
| 메모리 | 태그 + 페이로드 (포괄) | 태그 + 가장 큰 항 (좁음) |
| 점진성 | 점진 (코드 변경 없이 새 타입 OK) | 정적 (변형 추가는 타입 변경) |
2.6.7. enum 과의 차이
enum 의 경우에는 명명된 변형이 컨테이너 안에 정의되며, 변형 자체가 enum 의 일부가 됩니다.
이에 비해 합 타입은 기존 타입을 변형으로 그대로 쓰기 때문에 변형 이름이 따로 존재하지 않습니다.
enum Result { Ok(i32), Err(str) } // Ok / Err 변형 이름이 enum 안에 정의됨
type Either = i32 | str; // 변형 이름 없음 - 그냥 i32 또는 str
선택 기준은 다음과 같습니다. 서로 다른 의미를 가진 변형이라 이름이 필요한 경우에는 enum 을 선택하는 것이 적절합니다. 반대로 기존 타입을 그대로 합치는 쪽이 자연스러운 경우에는 합 타입을 선택하면 됩니다.
2.6.8. 좁히기 (narrowing)
합 타입의 값은 any 슬롯 과 같은 방식으로 패턴 매칭이나 is 검사를 통해 항을 좁힐 수 있습니다.
let v: i32 | str = ...;
match v {
n: i32 => ..., // 이 분기 안에서 v 는 i32
s: str => ..., // 이 분기 안에서 v 는 str
}
좁히기의 정확한 문법은 패턴과 표현식 사양에서 정의합니다.
2.6.9. type alias 와 결합
긴 합 타입은 type 으로 별칭을 줄 수 있습니다 (type Num = i32 | f64;). 순환하는 타입 별칭 (제네릭 인자를 거쳐도) 은 에러입니다. 타입 별칭의 where 는 별칭 대상 뒤에 둡니다 (type A<T> = B<T> where T: X;). 별칭의 where 는 별칭을 쓰는 자리마다 검사됩니다. 쓰는 자리의 인자가 타입 매개변수를 가리키면, 제네릭 본체에서처럼 (2.10.9) 그 자리의 범위에 있는 bound 가 정합니다: 둘러싼 함수, impl, 트레잇의 매개변수 bound 와 where 절, 트레잇의 supertrait 와 연관 타입 bound. type W<T> = S<T> where T: Num; 이면 fn get<T: Num>(x: W<T>) 와 impl<T: Num> W<T> { .. } 는 유효하고, fn get<T>(x: W<T>) 는 한 번도 호출하지 않아도 에러입니다. 선언의 타입에서도 마찬가지입니다: struct, enum, union 의 필드, 트레잇 impl 의 트레잇 인자와 연관 타입, 트레잇 메서드의 시그니처는 별칭의 where 를 만족하지 않으면, 아무것도 그것을 쓰지 않아도 거부됩니다. help 는 bound 를 더할 제네릭을 알립니다 (add `T: Num` to `F`'s generics). type 은 문맥 키워드로, 항목 시작에서 식별자와 = 또는 < 가 이어질 때만 키워드입니다 (1.5.4). 정확한 alias 문법은 타입 별칭 사양에서 정의합니다.
type Number = i32 | i64 | f32 | f64;
type Maybe<T> = T | none;