rokdocs
v0.1

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 | nonei32 | stri32 | str | none (겹침 없음 - 펼침)
T | nonei32 | none(i32 | none)? (두 층)
T | ParseErri32 | ParseErr(i32 | ParseErr) | ParseErr
T | str | nonei32 | 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) 은 닫힌 슬롯이어서, 명시된 변형만 가능합니다. 그 대신 컴파일러가 모든 가능한 항을 알고 있기 때문에 패턴 매칭의 완전성 검사를 수행할 수 있습니다.

측면anyA | 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;

GitHub에서 이 페이지 고치기