이 문서는 2판 번역본입니다.

최신 2021 에디션 문서는 https://doc.rust-kr.org 에서 확인하실 수 있습니다.

고급 타입

러스트의 타입 시스템은 이 책에서 언급은 했지만 아직 논의하지는 않았던 몇가지 기능들을 가지고 있습니다. 우리는 대개 왜 뉴타입이 타입으로서 유용한지를 시험함으로서 뉴타입에 대해 논하는 것으로 시작할 것입니다. 그 다음 뉴타입과 비슷안 기능이지만 약간 다른 의미를 가지고 있는 타입 별칭(type alias)으로 넘어가겠습니다. 또한 ! 타입과 동적인 크기의 (dynamically sized) 타입에 대해 논할 것입니다.

노트: 다음 절은 여러분이 이전 절 “외부 타입에 대해 외부 트레잇을 구현하기 위한 뉴타입 패턴”을 읽었음을 가정합니다.

타입 안전성과 추상화를 위한 뉴타입 패턴 사용하기

뉴타입 패턴은 우리가 지금까지 논했던 것 이상으로 다른 작업에 대해서도 유용한데, 여기에는 어떤 값이 혼동되지 않도록 정적으로 강제하는 것과 어떤 값의 단위 표시로서의 기능을 포함합니다. 여러분은 Listing 19-23에서 단위를 나타내기 위해 뉴타입을 사용하는 예제를 봤습니다: u32 값을 뉴타입으로 감싼 MillimetersMeters 구조체를 상기하세요. 만일 우리가 Millimeters 타입의 파라미터를 가지고 함수를 작성했다면, 의도치않게 그 함수에 Meters 타입의 값이나 그냥 u32 값을 넣어서 호출 시도를 하는 프로그램의 컴파일을 하지 못하게 됩니다.

뉴타입 패턴의 또다른 사용례는 어떤 타입의 몇몇 자세한 구현 사항을 추상화 하는 것입니다: 예를 들어 우리가 가능한 기능을 제약하기 위해 뉴타입을 직접 사용했다면 뉴타입은 내부의 비공개 타입이 가진 API와 다른 공개 API를 노출할 수 있습니다.

뉴타입은 또한 내부 구현사항을 숨길 수 있습니다. 예를 들어, 우리는 사람의 ID와 그의 이름을 저장하는 HashMap<i32, String>을 감싸는 People 타입을 제공할 수 있습니다. People을 사용하는 코드는 오직 우리가 제공하는 공개 API만을 통해 상호작용할 것이며, 여기에는 People 컬렉션에 이름 문자열을 추가하는 메소드 같은게 있겠지요; 이 코드에서는 우리가 내부적으로 이름에 대해 i32 ID를 할당한다는 점을 알 필요가 없을 것입니다. 뉴타입 패턴은 캡술화를 하여 자세한 구현 사항을 숨기기 위한 가벼운 방식으로, 캡술화에 대한 것은 17장의 “자세한 구현사항을 숨기는 캡슐화” 절에서 다루었습니다.

타입 별칭은 타입의 동의어를 만듭니다

뉴타입 패턴에 덧붙여서, 러스트는 존재하는 타입에게 다른 이름을 부여하기 위한 타입 별칭 (type alias) 선언 기능을 제공합니다. 이를 위해서는 type 키워드를 사용합니다. 예를 들어, 우리는 아래와 같이 i32에 대한 별칭 Kilometers를 생성할 수 있습니다:

#![allow(unused)]
fn main() {
type Kilometers = i32;
}

이제 별칭인 Kilometersi32동의어입니다; 우리가 Listing 19-23에서 만들었던 MillimetersMeters와는 달리, Kilometers는 분리된, 새로운 타입이 아닙니다. Kilometers 타입의 값은 i32 타입의 갑과 동일한 것으로 취급될 것입니다:

#![allow(unused)]
fn main() {
type Kilometers = i32;

let x: i32 = 5;
let y: Kilometers = 5;

println!("x + y = {}", x + y);
}

Kilometersi32가 동일한 타입이기 때문에, 우리는 두 타입의 값을 더할 수 있고 i32 파라미터를 갖는 함수에게 Kilometers 값을 넘길 수 있습니다. 그러나, 이 방법을 사용하면 우리는 앞서 논의했던 뉴타입 패턴이 제공하는 타입 검사의 이점을 얻지 못합니다.

타입 동의어의 주요 사용 사례는 반복 줄이기 입니다. 예를 들어, 우리는 아래와 같이 길다란 타입을 가질지도 모릅니다:

Box<Fn() + Send + 'static>

이러한 길다란 타입을 함수 시그니처 혹은 타입 명시로 코드의 모든 곳에 작성하는 것은 성가시고 에러를 내기도 쉽습니다. Listing 19-32와 같은 코드로 가득한 프로젝트가 있다고 상상해보세요.

#![allow(unused)]
fn main() {
let f: Box<Fn() + Send + 'static> = Box::new(|| println!("hi"));

fn takes_long_type(f: Box<Fn() + Send + 'static>) {
    // --snip--
}

fn returns_long_type() -> Box<Fn() + Send + 'static> {
    // --snip--
    Box::new(|| ())
}
}

Listing 19-32: 수많은 곳에 긴 타입을 사용하기

타입 별칭은 반복을 줄임으로서 이 코드의 관리를 더 잘되게끔 만들어줍니다. Listing 19-33에서 우리는 이 장황한 타입에 대해 Thunk라는 이름의 별칭을 도입해서 이 타입이 사용되는 모든 부분을 짧은 별칭인 Thunk로 대체할 수 있습니다.

#![allow(unused)]
fn main() {
type Thunk = Box<Fn() + Send + 'static>;

let f: Thunk = Box::new(|| println!("hi"));

fn takes_long_type(f: Thunk) {
    // --snip--
}

fn returns_long_type() -> Thunk {
    // --snip--
    Box::new(|| ())
}
}

Listing 19-33: 반복을 줄이기 위해 타입 별칭 Thunk을 도입하기

이 코드가 훨씬 읽고 쓰기 쉽습니다! 타입 별칭을 위한 의미잆는 이름을 고르는 것은 또한 여러분의 의도를 전달하는 데에 도움을 줄 수 있습니다 (thunk는 이후에 실행될 코드를 위한 단어로, 저장되는 클로저를 위한 적절한 이름입니다.)

타입 별칭은 또한 Result<T, E>타입의 반복을 줄이기 위해 흔하게 사용됩니다. 표준 라이브러리의 std::io 모듈을 고려해 보세요. I/O 연산들은 작동에 실패하는 상황을 다루기 위해서 자주 Result<T, E>을 반환합니다. 이 라이브러리는 모든 가능한 I/O 에러를 표현하는 std::io::Error 구조체를 가지고 있습니다. std::io 내의 많은 함수들이 Estd::io::ErrorResult<T, E>을 반환합니다. Write 트레잇의 아래 함수들 같이 말이죠:

#![allow(unused)]
fn main() {
use std::io::Error;
use std::fmt;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}
}

Result<..., Error>이 너무 많이 반복됩니다. 그렇기 때문에, std::io는 이 타입의 별칭 선언을 갖고 있습니다:

type Result<T> = Result<T, std::io::Error>;

이 선언이 std::io 모듈 내에 있으므로, 우리는 완전 정규화된 별칭 std::io::Result<T>을 사용할 수 있습니다; 이는 Estd::io::Error로 채워진 Result<T, E>입니다. Write 트레잇 함수 시그니처는 결국 아래와 같이 보이게 됩니다:

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: Arguments) -> Result<()>;
}

이 타입 별칭은 두 가지 방식으로 도움을 줍니다; 코드를 작성하기 더 편하게 해주고 그러면서도 모든 std::io에 걸쳐 일관된 인터페이스를 제공합니다. 이것이 별칭이기 때문에, 이것은 그저 또다른 Result<T, E>일 뿐이고, 이는 우리가 Result<T, E>을 가지고 쓸 수 있는 어떠한 메소드는 물론, ?같은 특별 문법도 사용할 수 있음을 의미합니다.

결코 반환하지 않는 ! 부정 타입

러스트는 !로 칭하는 특별한 타입을 가지고 있는데 타입 이론 용어에서는 이 타입이 값을 가지지 않기 때문에 빈 타입 (empty type) 으로 알려져 있습니다. 우리는 이를 부정 타입 (never type) 이라고 부르는 편을 선호하는데, 그 이유는 어떤 함수가 결코 값을 반환하지 않을 때 반환 타입의 자리에 대신하기 때문입니다. 아래에 예제가 있습니다:

fn bar() -> ! {
    // --snip--
}

이 코드는 “함수 bar가 결코 반환하지 않는다” 라고 읽힙니다. 결코 반환하지 않는 함수는 발산 함수 (diverging function) 라고 부릅니다. 우리는 ! 타입의 값을 만들수 없으므로 bar는 결코 반환이 가능하지 않습니다.

하지만 여러분이 값을 전혀 만들 수 없는 타입의 사용처는 무엇일까요? Listing 2-5의 코드를 상기해보세요; 여기 Listing 19-34에 재현해두었습니다.

#![allow(unused)]
fn main() {
let guess = "3";
loop {
let guess: u32 = match guess.trim().parse() {
    Ok(num) => num,
    Err(_) => continue,
};
break;
}
}

Listing 19-34: continue로 끝나는 갈래를 가진 match

이 시점에서, 이 코드의 몇가지 세부 사항은 생략하겠습니다. 6장의 “match 흐름 제어 연산자” 절에서, 우리는 match의 갈래들이 동일한 타입을 반환해야 한다고 논했습니다. 따라서, 예를 들어 다음과 같은 코드는 동작하지 않습니다:

let guess = match guess.trim().parse() {
    Ok(_) => 5,
    Err(_) => "hello",
}

이 코드의 guess 타입은 정수 문자열 이어야 할 것이고, 러스트는 guess가 단 하나의 타입을 가져야 함을 요구합니다. 그러면 continue가 반환하는 것은 무엇일까요? 어떻게 Listing 19-34에서 한 쪽의 갈래에서는 u32를 반환하고 다른 갈래에서는 continue로 끝나는 것이 허용되었을까요?

여러분이 짐작하셨던 것처럼, continue! 값을 갖습니다. 즉, 러스트가 guess의 타입을 계산할 때, 컴파일러는 매치의 두 갈래를 살펴보는데, 전자는 u32의 값이고 후자는 ! 값입니다. !가 값을 가질 수 없으므로, 러스트는 guess의 타입이 u32이라고 결정합니다.

이 동작을 기술하는 정규적인 방법은 타입 !의 표현식이 어떠한 다른 타입으로도 강제될 수 있다는 것입니다. continue가 값을 반환하지 않으므로 이 match의 갈래를 continue로 끝내는 것이 허용됩니다; 대신 실행 지점이 루프의 상단으로 이동되므로, 우리는 guess에 결코 값을 대입할 수 없습니다.

부정 타입은 또한 panic! 매크로에서도 유용하게 쓰입니다. Option<T> 값 상에서 값을 생산하거나 패닉을 일으키기 위해 호출한 unwrap 함수 기억하시죠? 여기 그 정의가 있습니다:

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

이 코드에서 Listing 19-34의 match와 동일한 일이 일어납니다: 러스트는 valT 타입을 갖고 panic!! 타입을 가지므로 전체 match 표현식의 결과값은 T라고 봅니다. 이 코드는 panic!이 값을 생산하지 않기 때문에 동작합니다; 패닉은 프로그램을 끝내죠. None 케이스에서는 unwrap으로부터의 값을 반환하지 않을 것이므로, 이 코드는 유효합니다.

! 타입을 갖는 마지막 하나의 표현식은 loop 입니다:

print!("forever ");

loop {
    print!("and ever ");
}

여기서 루프는 결코 끝나지 않으므로, !가 이 표현식의 값입니다. 그러나, break을 포함시키면 이는 참이 아니게 되는데, 이는 루프가 break에 도달했을 때 멈추게 될 것이기 때문입니다.

동적인 크기의 타입과 Sized

특정 타입의 값을 할당하기 위한 공간의 크기 등 특정한 세부사항을 알기 위한 러스트의 요구로 인하여, 타입 시스템에서 혼란할 수 있는 구석이 있습니다: 바로 동적인 크기의 타입 (dynamically sized type) 에 대한 개념입니다. 이따금 DST 혹은 크기 없는 타입 (unsized type) 이라고도 불리는 이 타입은 우리가 오직 런타임에서만 그 크기를 알 수 있는 값을 이용하는 코드를 작성할 수 있게 해줍니다.

우리가 이 책을 통틀어 사용해온 str이라고 불리우는 동적인 크기의 타입의 세부사항을 파해쳐봅시다. 그렇습니다. &str이 아니라 str 그 자체가 바로 DST 입니다. 우리는 그 문자열이 얼마나 긴지 런타임이 될때까지 알수 없는데, 이는 우리가 str 타입의 변수를 만들수도, str 타입의 인자를 가질수도 없음을 의미합니다. 아래의 동작하지 않는 코드를 고려해보세요:

let s1: str = "Hello there!";
let s2: str = "How's it going?";

러스트는 특정한 타입의 어떤 값을 위해 얼마나 많은 메모리를 할당해야 하는지 알 필요가 있으며, 하나의 타입의 모든 값은 동일한 크기의 메모리를 사용해야 합니다. 만일 러스트가 위의 코드의 작성을 허용한다면, 위의 두 str 값은 동일한 크기의 공간을 차지할 필요가 있을 것입니다. 그러나 이 둘은 서로 다른 길이를 가지고 있습니다: s1은 12 바이트의 저장소가 필요하고 `s2는 15가 필요하군요. 이것이 바로 동적인 크기의 타입을 보유하는 변수를 만들수 없는 이유입니다.

그러면 우리는 뭘 할까요? 위의 경우, 여러분은 이미 해답을 알고 있습니다: 우리는 s1s2의 타입을 str가 아닌 &str로 만듭니다. 4장의 “스트링 슬라이스” 절에서 슬라이스 데이터 구조는 슬라이스의 시작 위치와 길이를 저장한다고 얘기했던 것을 상기하세요.

따라서 &TT가 위치한 곳의 메모리 주소값을 저장한 단일값임에도 불구하고, &str두 개의 값입니다: str의 주소와 길이 말이죠. 그런 점에서, 우리는 &str 값의 크기를 컴파일 시점에 알 수 있습니다: 길이상 usize의 크기의 두 배가 되지요. 즉, 참조하고 있는 문자열의 길이가 얼마든 상관없이, 우리는 언제나 &str의 크기를 알 수 있습니다. 대개의 경우 이것이 러스트 내에서 동적인 크기의 타입이 사용되는 방식입니다: 이들은 동적인 정보의 크기를 저장하는 추가적인 메타데이터를 가지고 있습니다. 동적인 크기의 타입의 황금률은 우리가 언제나 동적인 크기의 타입의 값을 어떤 종류의 포인터에 저장해야 한다는 것입니다.

우리는 str을 모든 종류의 포인터와 결합할 수 있습니다: 예를 들어, Box<str> 혹은 Rc<str> 같은 것들 말이죠. 사실, 여러분은 다른 동적인 크기의 타입을 통해 이미 이를 보셨습니다: 바로 트레잇입니다. 모든 트레잇은 그 트레잇의 이름을 사용함으로서 참조할 수 있는 동적인 크기의 타입입니다. 17장의 “서로 다른 타입의 값을 허용하기 위한 트레잇 객체 사용하기” 절에서, 트레잇을 트레잇 객체로 사용하기 위해서는 이를 &Trait 혹은 Box<Trait>와 같은 식으로 포인터에 넣어야 한다고 언급했었습니다 (Rc<Trait> 또한 동작할 것입니다).

DST를 가지고 작업하기 위해서, 러스트는 어떤 타입의 크기를 컴파일 타임에 알 수 있는지 혹은 없는지를 결정하기 위해 Sized라는 이름의 특별한 트레잇을 가지고 있습니다. 이 트레잇은 크기가 컴파일 타임에 알려진 모든 것들에 대해 자동으로 구현됩니다. 추가적으로, 러스트는 암묵적으로 모든 제네릭 함수들에게 Sized를 바운드로 추가합니다. 즉, 아래와 같은 제네릭 함수의 정의는:

fn generic<T>(t: T) {
    // --snip--
}

실제로는 우리가 아래와 같이 작성한 것처럼 취급됩니다:

fn generic<T: Sized>(t: T) {
    // --snip--
}

기본적으로, 제네릭 함수는 컴파일 타임에 크기를 알 수 있는 타입에 대해서만 작동할 것입니다. 그러나, 여러분은 이 제한사항을 느슨하게 하기 위해 다음과 같은 특별 문법을 사용할 수 있습니다:

fn generic<T: ?Sized>(t: &T) {
    // --snip--
}

?Sized 트레잇 바운드는 Sized 트레잇 바운드의 반대 개념입니다: 우리는 이를 “TSized 일수도 있고 아닐 수도 있다” 라고 읽을 수 있습니다. 이 문법은 다른 트레잇들 말고 오직 Sized에 대해서만 사용 가능합니다.

또한 t 파라미터가 T에서 &T로 바뀐 점을 주목하세요. 이 타입이 Sized가 아닐지도 모르기 때문에, 우리는 이를 어떤 종류의 포인터 뒤에 놓고 사용할 필요가 있습니다. 위의 경우에서는 참조자를 선택했습니다.

다음으로는 함수와 클로저에 대해 다루겠습니다!