이량(異量) 타입

거의 항상, 우리는 타입이 정적으로 알려져 있고 양수의 크기를 가지고 있다고 생각합니다. 러스트에서 이것은 항상 그렇지는 않습니다.

동량(動量) 타입 (DST)

러스트는 동량(動量) 타입(DST)을 지원합니다: 정적으로 알려진 크기나 정렬선이 없는 타입을 말이죠. 표면적으로는 이것은 좀 말이 되지 않습니다: 러스트는 무언가와 올바르게 작업하기 위해서는 그것의 크기와 정렬선을 알아야 하거든요! 이런 면에서 DST는 보통의 타입이 아닙니다. 정적으로 알려진 크기가 없기 때문에, 이런 타입들은 포인터 뒤에서만 존재할 수 있습니다. 따라서 DST를 가리키는 포인터는 포인터와 DST를 "완성하는" 정보로 이루어진 넓은 포인터가 됩니다 (밑에서 더 설명합니다).

언어에서 보이는 주요한 DST는 두 가지가 있습니다:

  • 트레잇 객체: dyn MyTrait
  • 슬라이스: [T], str, 등등

트레잇 객체는 그것이 특정하는 트레잇을 구현하는 어떤 타입을 표현합니다. 정확한 원래 타입은 런타임 리플렉션을 위해 지워지고, 타입을 쓰기 위해 필요한 모든 정보를 담고 있는 vtable로 대체됩니다. 트레잇 객체를 완성하는 정보는 이 vtable의 포인터입니다. 포인터가 가리키는 대상의 런타임 크기는 vtable에서 동적으로 요청될 수 있습니다.

슬라이스는 어떤 연속적인 저장소에 대한 뷰일 뿐입니다 -- 보통 이 저장소는 배열이거나 Vec입니다. 슬라이스 포인터를 완성시키는 정보는 가리키고 있는 원소들의 갯수입니다. 가리키는 대상의 런타임 크기는 그냥 한 원소의 정적으로 알려진 크기와 원소들의 갯수를 곱한 것입니다.

구조체는 사실 마지막 필드로써 하나의 동량 타입을 직접 저장할 수 있지만, 그러면 그들 자신도 동량 타입이 됩니다:

#![allow(unused)]
fn main() {
// 직접적으로 스택에 저장할 수 없음
struct MySuperSlice {
    info: u32,
    data: [u8],
}
}

이런 타입은 생성할 방법이 없으면 별로 쓸모가 없지만 말이죠. 현재 유일하게 제대로 지원되는, 커스텀 동량 타입을 만들 방법은 타입을 제네릭으로 만들고 크기 강제 망각을 실행하는 것입니다:

struct MySuperSliceable<T: ?Sized> {
    info: u32,
    data: T,
}

fn main() {
    let sized: MySuperSliceable<[u8; 8]> = MySuperSliceable {
        info: 17,
        data: [0; 8],
    };

    let dynamic: &MySuperSliceable<[u8]> = &sized;

    // 출력: "17 [0, 0, 0, 0, 0, 0, 0, 0]"
    println!("{} {:?}", dynamic.info, &dynamic.data);
}

(네, 커스텀 동량 타입은 지금으로써는 매우 설익은 기능입니다.)

무량(無量) 타입 (ZST)

러스트는 타입이 공간을 차지하지 않는다고 말하는 것도 허용합니다:

#![allow(unused)]
fn main() {
struct Nothing; // 필드 없음 = 크기 없음

// 모든 필드가 크기 없음 = 크기 없음
struct LotsOfNothing {
    foo: Nothing,
    qux: (),      // 빈 튜플은 크기가 없습니다
    baz: [u8; 0], // 빈 배열은 크기가 없습니다
}
}

무량(無量) 타입(ZST)은, 당연하게도 그 자체로는, 별로 쓸모가 없습니다. 하지만 러스트의 많은 기이한 레이아웃 선택들이 그렇듯이, 그들의 잠재력은 일반적인 환경에서 빛나게 됩니다: 러스트는 무량 타입의 값을 생성하거나 저장하는 모든 작업이 아무 작업도 하지 않는 것과 같을 수 있다는 사실을 매우 이해하거든요. 일단 값을 저장한다는 것부터가 말이 안됩니다 -- 차지하는 공간도 없는걸요. 또 그 타입의 값은 오직 하나이므로, 어떤 값이 읽히든 그냥 무에서 값을 만들어내면 됩니다 -- 이것 또한 차지하는 공간이 없기 때문에, 아무것도 하지 않는 것과 같습니다.

이것의 가장 극단적인 예시 중 하나가 MapSet입니다. Map<Key, Value>가 주어졌을 때, Set<Key>Map<Key, UselessJunk>를 적당히 감싸는 자료구조로 만드는 것은 흔하게 볼 수 있습니다. 많은 언어들에서 이것은 UselessJunk 타입을 위한 공간을 할당하고, UselessJunk를 가지고 아무것도 하지 않기 위해서 그 값을 저장하고 읽는 작업을 강제할 겁니다. 이 작업이 불필요하다는 것을 증명하려면 컴파일러는 복잡한 분석을 해야 할 겁니다.

그러나 러스트에서는 우리는 그냥 Set<Key> = Map<Key, ()>라고 말할 수 있습니다. 이제 러스트는 컴파일할 때 모든 메모리 읽기와 저장은 의미가 없고, 저장 공간을 할당할 필요도 없다는 것을 알게 됩니다. 결과적으로 나오는 코드는 그냥 HashSet의 커스텀 구현일 뿐이고, HashMap이 값을 처리할 때의 연산은 존재하지 않게 됩니다.

안전한 코드는 무량 타입에 대해서 걱정하지 않아도 되지만, 불안전한 코드는 크기가 없는 타입의 중요성을 신경써야 합니다. 특히 포인터 오프셋은 아무 작업도 하지 않는 것과 같고, 할당자는 보통 0이 아닌 크기를 요구합니다.

무량 타입을 가리키는 레퍼런스(빈 슬라이스 포함)는 다른 레퍼런스와 마찬가지로, 널이 아니고 잘 정렬되어 있어야 합니다. 무량 타입을 가리키지만 널이나 정렬되지 않은 포인터를 역참조하는 것 역시, 다른 타입들과 마찬가지로 미정의 동작입니다.

빈 타입

러스트는 또한 그 타입의 값을 만들 수조차 없는 타입을 정의하는 것도 지원합니다. 이런 타입들은 타입 측면에서만 말할 수 있고, 값 측면에서는 절대 말할 수 없습니다. 빈 타입은 형이 없는 열거형을 정의함으로써 만들 수 있습니다:

#![allow(unused)]
fn main() {
enum Void {} // 형 없음 = 비어 있음
}

빈 타입은 무량 타입보다도 더 작습니다. 빈 타입의 예시로 들 만한 것은 타입 측면에서의 접근불가성입니다. 예를 들어, 어떤 API가 일반적으로 Result를 반환해야 하지만, 어떤 경우에서는 실패할 수 없다고 합시다. 우리는 이것을 Result<T, Void>를 반환함으로써 타입 레벨에서 소통할 수 있습니다. API의 사용자들은 이런 Result의 값이 Err가 되기에 정적으로 불가능하다는 것을 알고 자신 있게 unwrap할 수 있을 겁니다, 왜냐하면 Err 값이 있으려면 Void 타입의 값이 생산되어야 하거든요.

원칙적으로는 러스트가 이런 사실에 기반하여 몇 가지 흥미로운 분석과 최적화를 수행할 수 있을 겁니다. 예를 들어 Result<T, Void>Err 형이 실제로 존재하지 않기에, 그냥 T로 표현할 수 있겠죠 (엄격하게 말하면, 이것은 보장되지 않은 최적화일 뿐이고, TResult<T, Void> 중 하나를 다른 하나로 변질시키는 것은 아직 미정의 동작입니다).

다음의 코드도 컴파일 됩니다:

#![allow(unused)]
fn main() {
enum Void {}

let res: Result<u32, Void> = Ok(0);

// Err 형이 존재하지 않으므로, Ok 형은 사실 패턴 매칭이 실패할 수 없습니다.
let Ok(num) = res;
}

빈 타입에 대한 마지막 하나의 조그만 사실은, 빈 타입을 가리키는 생 포인터는 놀랍게도 유효하게 생성할 수 있지만, 그것을 역참조하는 것은 말이 안되기 때문에 미정의 동작이라는 것입니다.

우리는 C의 void* 타입을 *const Void로 설계하는 것을 추천하지 않습니다. 많은 사람들이 이렇게 했지만 얼마 지나지 않아 문제에 부딪혔는데, 러스트는 불안전한 코드로 빈 타입의 값을 만드려고 하는 것을 막는 안전 장치가 없고, 만약 빈 타입의 값을 만들면, 그것은 미정의 동작이기 때문입니다. 이것은 특별히 문제가 되었는데, 개발자들이 생 포인터를 레퍼런스로 바꾸는 습관이 있었고 &Void 값을 만드는 것 역시 미정의 동작이기 때문입니다.

*const () (혹은 비슷한 타입)은 void*에 대응해서 무리 없이 잘 동작하고, 안전성의 문제 없이 레퍼런스로 만들 수 있습니다. 값을 읽고 쓰려고 하는 시도를 막는 것은 여전히 하지 않지만, 최소한 미정의 동작보다는 아무 작업도 하지 않는 것으로 컴파일됩니다.

외래 타입

외래 타입으로 불리는, 알 수 없는 크기의 타입을 추가하여 러스트 개발자들이 C의 void*나 다른 "선언되었지만 정의되지 않은" 타입들을 좀더 정확하게 설계하자는, 승인된 RFC가 있습니다. 하지만 러스트 2018 기준으로, 이 기능은 size_of_val::<MyExternType>()이 어떻게 작동해야 하는지에 걸려서 대기 상태에 갇혀 있습니다.