고급 타입

러스트의 타입 시스템에는 지금까지 언급은 했지만 아직 논의하지는 않은 몇 가지 기능이 있습니다. 먼저 뉴타입이 타입으로써 유용한 이유를 살펴보면서 뉴타입에 대해 전반적으로 논의하겠습니다. 그런 다음 뉴타입과 비슷하지만 의미는 약간 다른 기능인 타입 별칭 (type alias) 에 대해 살펴보겠습니다. 또한 ! 타입과 동적 크기 타입 (dynamically sized type) 에 대해서도 설명합니다.

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

Note: 이 절은 여러분이 이전에 나온 ‘뉴타입 패턴을 사용하여 외부 타입에 외부 트레이트 구현하기’절을 읽었다고 가정합니다.

뉴타입 패턴은 지금까지 설명한 것 이외의 작업에도 유용한데, 여기에는 값이 혼동되지 않도록 정적으로 강제하는 것과 값의 단위를 표시하는 것들이 포함됩니다. 예제 19-15에서 뉴타입을 사용하여 단위를 표시하는 예제를 보았습니다: MillimetersMeters 구조체가 u32 값을 뉴타입으로 감싸고 있었음을 상기하세요. Millimeters 타입의 매개변수가 있는 함수를 작성했다면, 실수로 Meters 또는 보통의 u32 타입의 값으로 해당 함수를 호출 시도하는 프로그램은 컴파일될 수 없습니다.

뉴타입 패턴은 어떤 타입의 구현 세부 사항을 추상화하는데도 사용 가능합니다: 뉴타입은 비공개 내부 타입의 API와는 다른 공개 API를 노출할 수 있습니다.

뉴타입은 내부 구현을 숨길 수도 있습니다. 예를 들면, 어떤 사람의 ID와 이에 연관된 그 사람의 이름을 저장하는 HashMap<i32, String>을 감싼 People 타입을 만들 수 있습니다. People을 사용하는 코드는 People 컬렉션에 이름 문자열을 추가하는 메서드처럼 우리가 제공하는 공개 API와만 상호작용할 수 있습니다; 해당 코드는 내부적으로 이름에 i32 ID를 할당한다는 사실을 알 필요가 없습니다. 뉴타입 패턴은 구현 세부 사항을 숨기는 캡슐화를 달성하는 가벼운 방법으로, 17장의 ‘상세 구현을 은닉하는 캡슐화’절에서 설명한 바 있습니다.

타입 별칭으로 타입의 동의어 만들기

러스트는 타입 별칭 (type alias) 을 선언하여 기존 타입에 다른 이름을 부여하는 기능을 제공합니다. 이를 위해서는 type 키워드를 사용합니다. 예를 들어, 다음과 같이 i32에 대한 Kilometers라는 별칭을 만들 수 있습니다:

fn main() {
    type Kilometers = i32;

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

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

이제 별칭 Kilometersi32동의어입니다; 예제 19-15에서 만든 MillimetersMeters 타입과는 달리, Kilometers는 별도의 새로운 타입은 아닙니다. Kilometers 타입을 가진 값은 i32 타입의 값과 동일하게 처리됩니다:

fn main() {
    type Kilometers = i32;

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

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

Kilometersi32는 동일한 타입이므로 두 타입의 값을 모두 더할 수 있고 Kilometers 값을 i32 매개변수를 받는 함수에 전달할 수 있습니다. 그러나, 이 방법을 사용하면 이전에 설명한 뉴타입 패턴에서 얻을 수 있는 타입 검사 이점을 얻을 수 없습니다. 다시 말해, 어딘가에서 Kilometersi32 값을 혼용하면 컴파일러는 에러를 표시하지 않습니다.

타입 동의어의 주요 사용 사례는 반복을 줄이는 것입니다. 예를 들어, 다음과 같은 긴 타입이 있을 수 있습니다:

Box<dyn Fn() + Send + 'static>

이 긴 타입을 함수 시그니처 및 코드의 모든 곳에 타입 명시로 작성하는 것은 지루하고 에러가 발생하기 쉽습니다. 예제 19-24와 같은 코드로 가득 찬 프로젝트가 있다고 상상해 보세요.

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

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

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

예제 19-24: 수많은 곳에 긴 타입 사용하기

타입 별칭은 반복을 줄여 이 코드를 관리하기 쉽게 만듭니다. 예제 19-25에서는 이 장황한 타입에 대해 Thunk라는 별칭을 만들고 이 타입이 사용된 모든 곳을 짧은 별칭 Thunk로 대체했습니다.

fn main() {
    type Thunk = Box<dyn Fn() + Send + 'static>;

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

    fn takes_long_type(f: Thunk) {
        // --생략--
    }

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

예제 19-25: 타입 별칭 Thunk을 도입하여 반복 줄이기

이 코드는 읽고 작성하기 훨씬 쉽습니다! 또한 타입 별칭에 의미 있는 이름을 선택하면 의도를 전달하는 데 도움이 됩니다. (thunk는 나중에 평가될 코드를 나타내는 단어이므로, 저장되는 클로저에 적합한 이름입니다.)

타입 별칭은 또한 Result<T, E> 타입의 반복을 줄이기 위해 사용되기도 합니다. 표준 라이브러리의 std::io 모듈을 생각해 보세요. I/O 연산은 종종 연산이 작동하지 않을 때의 상황을 처리하기 위해 Result<T, E>를 반환합니다. 이 라이브러리에는 가능한 모든 I/O 에러를 나타내는 std::io::Error 구조체가 있습니다. std::io의 많은 함수는 Write 트레이트의 함수와 같이 Estd::io::ErrorResult<T, E>를 반환합니다:

use std::fmt;
use std::io::Error;

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에는 이러한 타입 별칭 선언이 있습니다:

use std::fmt;

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

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: fmt::Arguments) -> Result<()>;
}

이 선언이 std::io 모듈에 있으므로, 완전 정규화된 별칭 std::io::Result<T>를 사용할 수 있습니다; 즉, Estd::io::Error로 채워진 Result<T, E>입니다. Write 트레이트 함수 시그니처는 결국 다음과 같이 생기게 됩니다:

use std::fmt;

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

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: fmt::Arguments) -> Result<()>;
}

타입 별칭은 두 가지 방법으로 도움을 줍니다: 코드를 쉽게 작성할 수 있게 해 주고, 그러면서도 std::io 전체에 일관된 인터페이스를 제공합니다. 이것은 별칭이기 때문에 그저 또 다른 Result<T, E>일 뿐이고, 이는 Result<T, E>에서 작동하는 모든 메서드는 물론, ? 연산자와 같은 특별 문법도 사용할 수 있음을 뜻합니다.

절대 반환하지 않는 부정 타입

러스트에는 !라는 특수한 타입이 있는데, 이 타입은 값이 없기 때문에 타입 이론 용어로는 빈 타입 (empty type) 이라고 알려져 있습니다. 함수가 절대 반환하지 않을 때 반환 타입을 대신하기 때문에 부정 타입 (never type) 이라고 부르는 쪽이 선호됩니다. 다음은 예시입니다:

fn bar() -> ! {
    // --생략--
    panic!();
}

이 코드는 ‘함수 bar는 절대로 반환하지 않습니다’라고 읽습니다. 절대로 반환하지 않는 함수는 발산 함수 (diverging functions) 라고 합니다. ! 타입의 값은 만들 수 없으므로, bar는 절대 반환할 수 없습니다.

그런데 값을 절대로 만들 수 없는 타입은 어디에 쓰는 거죠? 숫자 추리 게임의 부분인 예제 2-5의 코드를 기억해 보세요; 여기에 예제 19-26에서 다시 일부를 재현해 두었습니다.

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --생략--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --생략--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

예제 19-26: continue로 끝나는 갈래가 있는 match

이 시점에서는 이 코드에서 몇 가지 세부 사항을 건너뛰었습니다. 6장의 match 제어 흐름 연산자’절에서 match 갈래가 모두 같은 타입을 반환해야 한다는 것을 논의했습니다. 예를 들어, 다음 코드는 작동하지 않습니다:

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

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

짐작하셨겠지만, continue! 값을 가집니다. 즉, 러스트가 guess의 타입을 계산할 때, 두 개의 매치 갈래를 모두 살펴보게 되는데, 전자는 u32 값을 가지고 후자는 ! 값을 가집니다. !는 절대로 값을 가질 수 없으므로, 러스트는 guess의 타입이 u32라고 결정합니다.

이 동작을 설명하는 공식적인 방법은 ! 타입의 표현식이 다른 모든 타입으로 강제 변환될 수 있다는 것입니다. continue가 값을 반환하지 않기 때문에, 이 match 갈래가 continue로 끝나도 괜찮습니다; continue는 제어를 반복문의 맨 위로 이동시키기 때문에, Err 케이스에서는 guess에 값을 할당하지 않습니다.

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

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

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

이 코드에서도 예제 19-26의 match에서와 같은 일이 발생합니다: valT 타입을 가지고 있고 panic!! 타입을 가지고 있으므로, 러스트는 전체 match 표현식의 결과가 T라는 것을 알 수 있습니다. 이 코드는 panic!이 값을 생성하지 않기 때문에 작동합니다; 패닉은 프로그램을 종료하니까요. None의 경우 unwrap에서 값을 반환하지 않으므로, 이 코드는 유효합니다.

! 타입을 가지는 마지막 표현식은 loop입니다:

fn main() {
    print!("forever ");

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

여기서 루프는 절대 끝나지 않으므로, !가 이 표현식의 값이 됩니다. 하지만 break를 포함시키면, 루프는 break에 도달했을 때 종료되므로, 이는 참이 아니게 될 것입니다.

동적 크기 타입과 Sized 트레이트

러스트는 특정 타입의 값에 할당할 공간의 크기 등 타입에 대한 특정 세부 사항을 알아야 합니다. 이로 인해 처음에는 타입 시스템의 한구석이 약간 혼란스럽습니다: 바로 동적 크기 타입 (dynamically sized type) 의 개념이 그렇습니다. DST 또는 크기가 지정되지 않은 타입 (unsized type) 이라고도 하는 이러한 타입을 사용하면 런타임에만 크기를 알 수 있는 값을 사용하여 코드를 작성할 수 있습니다.

이 책 전체에 걸쳐 사용했던 str이라는 동적 크기 타입에 대해 자세히 알아보겠습니다. 그렇습니다. &str이 아니라 str 자체는 DST입니다. 런타임이 될 때까지 문자열의 길이를 알 수 없으므로 str 타입의 변수를 만들 수도 없고, str 타입의 인수를 받을 수도 없습니다. 아래의 작동하지 않는 코드를 고려해 보세요:

fn main() {
    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의 크기를 알 수 있습니다. 일반적으로 이것이 러스트에서 동적 크기 타입이 사용되는 방식입니다: 이들은 동적 정보의 크기를 저장하는 추가 메타데이터를 가지고 있습니다. 동적 크기 타입의 황금률은 동적 크기 타입의 값을 항상 어떤 종류의 포인터 뒤에 넣어야 한다는 것입니다.

str은 모든 종류의 포인터와 결합할 수 있습니다: 예를 들면, Box<str>Rc<str> 같은 것들이지요. 사실, 여러분은 이전에도 다른 종류의 동적 크기 타입이지만 이런 것을 본 적이 있습니다: 바로 트레이트입니다. 모든 트레이트는 그 트레이트의 이름을 사용하여 참조할 수 있는 동적 크기 타입입니다. 17장의 ‘트레이트 객체를 사용하여 다른 타입의 값 허용하기’절에서, 트레이트를 트레이트 객체로 사용하려면 &dyn Trait 또는 Box<dyn Trait>와 같은 포인터 뒤에 넣어야 한다고 언급했습니다. (Rc<dyn Trait>도 가능합니다.)

DST로 작업하기 위해 러스트에서는 컴파일 시점에 타입의 크기를 알 수 있는지 여부를 결정하는 Sized 트레이트를 제공합니다. 이 트레이트는 컴파일 시 크기가 알려진 모든 것에 대해 자동으로 구현됩니다. 또한 러스트는 암묵적으로 모든 제네릭 함수에 Sized 바운드를 추가합니다. 즉, 다음과 같은 제네릭 함수 정의는:

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

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

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

기본적으로 제네릭 함수는 컴파일 시점에 크기가 알려진 타입에 대해서만 작동합니다. 그러나 다음과 같은 특별 문법을 사용하여 이 제한을 완화할 수 있습니다:

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

?Size 트레이트 바운드는 ‘TSized일 수도 있고 아닐 수도 있다’는 의미를 가지며 이 문법은 제네릭 타입이 컴파일 시점에 크기가 알려진 타입이어야 한다는 기본값을 덮어씁니다. 이런 의미의 ?Trait 문법은 Sized에만 사용할 수 있고 다른 어떤 트레이트에도 사용할 수 없습니다.

또한 t 매개변수의 타입을 T에서 &T로 바꾸었음을 주목하세요. 타입이 Sized가 아닐 수 있기 때문에 어떤 종류의 포인터 뒤에 놓고 사용해야 합니다. 이 경우에는 참조를 선택했습니다.

다음으로는 함수와 클로저에 대해 이야기해 보겠습니다!