고급 타입

고급 타입 (Advanced Types)

Rust의 타입 시스템에는 지금까지 언급만 하고 깊게 다루지 않은 기능들이 몇 개 있어요. 먼저 newtype을 타입으로서 왜 유용한지 살펴보면서 전반적으로 다루고, 그다음 newtype과 비슷하지만 의미가 조금 다른 타입 별칭(type alias)으로 넘어갈게요. 마지막으로 ! 타입과 동적 크기 타입(dynamically sized type)도 짚고요.

출처: The Rust Book

newtype 패턴으로 타입 안전성과 추상화 얻기 (Type Safety and Abstraction with the Newtype Pattern)

이 절은 앞서 다룬 "Implementing External Traits with the Newtype Pattern" 절을 읽었다고 가정해요. newtype 패턴은 지금까지 다룬 것 이상의 작업에도 유용해요. 값을 절대 혼동하지 않도록 정적으로 강제하거나, 값의 단위를 나타내는 데 특히 그렇죠. newtype으로 단위를 나타내는 예시는 이전에 Listing 20-16에서 봤어요. MillimetersMeters 구조체가 u32 값을 newtype으로 감쌌던 걸 떠올려 보세요. Millimeters 타입의 파라미터를 받는 함수를 작성했다면, 실수로 Meters 타입이나 그냥 u32 값으로 그 함수를 호출하려는 프로그램은 컴파일되지 않을 거예요.

newtype 패턴으로 타입의 구현 세부사항 일부를 추상화할 수도 있어요. 새 타입은 비공개 내부 타입의 API와는 다른 공개 API를 노출할 수 있죠.

newtype은 또 내부 구현을 숨길 수도 있어요. 예를 들어 사람의 ID를 이름과 연결해서 저장하는 HashMap<i32, String>을 감싸는 People 타입을 제공할 수 있어요. People을 쓰는 코드는 우리가 제공하는 공개 API와만 상호작용할 텐데, 예를 들어 이름 문자열을 People 컬렉션에 추가하는 메서드 같은 것이죠. 그 코드는 내부적으로 이름에 i32 ID를 부여한다는 걸 알 필요가 없어요. newtype 패턴은 구현 세부사항을 숨기는 캡슐화(encapsulation)를 이룰 수 있는 가벼운 방법으로, 이는 18장의 "Encapsulation that Hides Implementation Details" 절에서 다뤘죠.

타입 시노님과 타입 별칭 (Type Synonyms and Type Aliases)

Rust는 기존 타입에 다른 이름을 주는 _타입 별칭(type alias)_을 선언할 수 있게 해줘요. 이를 위해 type 키워드를 쓰죠. 예를 들어 i32Kilometers라는 별칭을 이렇게 만들 수 있어요.

fn main() {
    type Kilometers = i32;

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

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

이제 Kilometers 별칭은 i32의 _시노님(synonym)_이에요. Listing 20-16에서 만든 MillimetersMeters 타입과 달리, Kilometers는 별개의 새 타입이 아니죠. Kilometers 타입의 값은 i32 타입의 값과 똑같이 취급돼요.

fn main() {
    type Kilometers = i32;

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

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

Kilometersi32는 같은 타입이므로 두 타입의 값을 더할 수 있고, i32 파라미터를 받는 함수에 Kilometers 값을 넘길 수 있어요. 하지만 이 방법으로는 앞서 다룬 newtype 패턴에서 얻는 타입 검사 혜택을 얻지 못해요. 다시 말해 어딘가에서 Kilometersi32 값을 섞어 써도 컴파일러가 오류를 주지 않아요.

타입 시노님의 주요 용도는 반복을 줄이는 거예요. 예를 들어 이런 긴 타입이 있다고 해볼게요.

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

이 긴 타입을 함수 시그니처와 타입 어노테이션에서 코드 곳곳에 쓰는 건 피곤하고 오류를 유발하기 쉬워요. Listing 20-25처럼 그런 코드로 가득한 프로젝트를 상상해 보세요.

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

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

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

타입 별칭은 반복을 줄여 이 코드를 더 다루기 쉽게 만들어요. Listing 20-26에서는 장황한 타입에 Thunk라는 별칭을 도입하고, 그 타입을 쓰는 모든 곳을 더 짧은 별칭 Thunk로 바꿀 수 있어요.

fn main() {
    type Thunk = Box<dyn 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(|| ())
    }
}

이 코드는 읽고 쓰기가 훨씬 쉬워요! 타입 별칭에 의미 있는 이름을 고르면 의도를 전달하는 데도 도움이 돼요(_thunk_는 나중에 평가될 코드를 뜻하는 단어라, 저장되는 클로저에 어울리는 이름이에요).

타입 별칭은 반복을 줄이기 위해 Result<T, E> 타입과도 흔히 쓰여요. 표준 라이브러리의 std::io 모듈을 생각해 보세요. I/O 연산은 작업이 실패하는 상황을 처리하기 위해 종종 Result<T, E>를 반환해요. 이 라이브러리에는 가능한 모든 I/O 오류를 나타내는 std::io::Error 구조체가 있어요. std::io의 많은 함수는 Estd::io::ErrorResult<T, E>를 반환하는데, Write 트레이트의 함수들처럼요.

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<()>;
}

타입 별칭은 두 가지로 도움이 돼요. 코드를 더 쉽게 쓰게 해주고(and), std::io 전체에 걸쳐 일관된 인터페이스를 주죠. 별칭이므로 그냥 또 다른 Result<T, E>라서, Result<T, E>에서 동작하는 어떤 메서드든 물론 ? 연산자 같은 특별한 문법도 그대로 쓸 수 있어요.

절대 반환하지 않는 never 타입 (The Never Type That Never Returns)

Rust에는 !라는 특수 타입이 있어요. 타입 이론 용어로는 _empty type(공 타입)_이라고 하는데, 값을 하나도 갖지 않기 때문이죠. 우리는 이를 _never type(절대 탐타입)_이라고 부르는 걸 선호해요. 함수가 절대 반환하지 않을 때 반환 타입 자리를 대신하기 때문이에요. 예시를 볼게요.

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

이 코드는 "함수 bar는 never를 반환한다"로 읽어요. never를 반환하는 함수를 _발산 함수(diverging function)_라 불러요. ! 타입의 값을 만들 수 없으므로 bar는 절대 반환할 수 없어요.

하지만 값을 절대 만들 수 없는 타입이 무슨 소용일까요? 숫자 맞추기 게임의 일부였던 Listing 2-5의 코드를 떠올려 보세요. 여기 Listing 20-27에 조금 재현했어요.

use std::cmp::Ordering;
use std::io;

use rand::Rng;

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();

        // --snip--

        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}");

        // --snip--

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

그 당시엔 이 코드의 몇몇 세부사항을 건너뛰었어요. 6장의 "The match Control Flow Construct" 절에서 match의 모든 팔(arm)이 같은 타입을 반환해야 한다고 다뤘죠. 예를 들어 다음 코드는 동작하지 않아요.

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

이 코드에서 guess의 타입은 정수 이자 문자열이어야 하는데, Rust는 guess가 단 하나의 타입만 갖길 요구해요. 그럼 continue는 무엇을 반환할까요? Listing 20-27에서 한 팔에서는 u32를 반환하고 다른 팔은 continue로 끝나게 어떻게 허용된 걸까요?

이미 짐작했듯이 continue! 값을 가져요. 즉 Rust가 guess의 타입을 계산할 때 두 match 팔을 보는데, 전자는 u32 값이고 후자는 ! 값이에요. !는 절대 값을 가질 수 없으므로, Rust는 guess의 타입이 u32라고 결정해요.

이 동작을 공식적으로 설명하면, ! 타입의 표현식은 어떤 다른 타입으로든 강제(coerce)될 수 있다는 거예요. 이 match 팔을 continue로 끝내는 게 허용되는 이유는 continue가 값을 반환하지 않고 대신 제어 흐름을 루프 맨 위로 옮기기 때문이에요. 그래서 Err 경우에는 guess에 값을 할당하지 않죠.

never 타입은 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"),
        }
    }
}

이 코드에서 Listing 20-27의 match와 똑같은 일이 일어나요. Rust는 val이 타입 T를 갖고 panic!은 타입 !를 갖는 걸 보고, 전체 match 표현식의 결과가 T라고 판단해요. 이 코드가 동작하는 이유는 panic!이 값을 만들지 않고 프로그램을 끝내기 때문이에요. None 경우에는 unwrap에서 값을 반환하지 않으므로 이 코드는 유효해요.

! 타입을 갖는 마지막 표현식은 루프예요.

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

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

여기서 루프는 절대 끝나지 않으므로 !가 표현식의 값이에요. 하지만 break를 포함하면 이는 성립하지 않아요. 루프가 break에 도달하면 종료되니까요.

동적 크기 타입과 Sized 트레이트 (Dynamically Sized Types and the Sized Trait)

Rust는 타입에 대해 몇 가지 세부사항을 알아야 해요. 예를 들어 특정 타입의 값에 얼마나 많은 공간을 할당할지 같은 것들이죠. 그래서 타입 시스템의 한 구석이 처음엔 조금 헷갈리는데, 바로 동적 크기 타입(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?";
}

Rust는 특정 타입의 값에 얼마나 많은 메모리를 할당해야 할지 알아야 하고, 한 타입의 모든 값은 같은 크기의 메모리를 써야 해요. Rust가 이 코드를 허용한다면, 두 str 값은 같은 크기의 공간을 차지해야 하는데, 길이가 서로 다르죠. s1은 저장에 12바이트가 필요하고 s2는 15바이트가 필요해요. 그래서 동적 크기 타입을 담는 변수를 만드는 게 불가능한 거예요.

그럼 어떻게 할까요? 이 경우 답은 이미 알고 있어요. s1s2의 타입을 str이 아니라 문자열 슬라이스(&str)로 만드는 거예요. 4장의 "String Slices" 절에서 슬라이스 데이터 구조는 슬라이스의 시작 위치와 길이만 저장한다는 걸 떠올려 보세요. &TT가 위치한 메모리 주소를 저장하는 단일 값이지만, 문자열 슬라이스는 값이에요. str의 주소와 길이죠. 따라서 문자열 슬라이스 값의 크기는 컴파일 시점에 알 수 있어요. usize 길이의 두 배이거든요. 가리키는 문자열이 아무리 길어도 문자열 슬라이스의 크기는 항상 알 수 있다는 뜻이에요. 일반적으로 이것이 Rust에서 동적 크기 타입이 사용되는 방식이에요. 동적 정보의 크기를 저장하는 메타데이터가 조금 더 있는 거죠. 동적 크기 타입의 황금률은 동적 크기 타입의 값을 항상 어떤 종류의 포인터 뒤에 놓아야 한다는 거예요.

str은 여러 종류의 포인터와 결합할 수 있어요. 예를 들어 Box<str>이나 Rc<str>처럼요. 사실 이전에도 봤지만 다른 동적 크기 타입이었어요. 바로 트레이트죠. 모든 트레이트는 이름으로 참조할 수 있는 동적 크기 타입이에요. 18장의 "Using Trait Objects to Abstract over Shared Behavior" 절에서, 트레이트를 트레이트 객체로 쓰려면 포인터 뒤에 놓아야 한다고 언급했죠. &dyn TraitBox<dyn Trait>처럼요(Rc<dyn Trait>도 동작해요).

DST를 다루기 위해 Rust는 타입의 크기가 컴파일 시점에 알려져 있는지 판단하는 Sized 트레이트를 제공해요. 이 트레이트는 크기가 컴파일 시점에 알려진 모든 것에 자동으로 구현돼요. 게다가 Rust는 모든 제네릭 함수에 Sized 바운드를 암묵적으로 추가해요. 즉 이런 제네릭 함수 정의는

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

실제로 이렇게 쓴 것처럼 취급돼요.

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

기본적으로 제네릭 함수는 컴파일 시점에 크기가 알려진 타입에서만 동작해요. 하지만 다음 특별한 문법으로 이 제한을 완화할 수 있어요.

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

?Sized에 대한 트레이트 바운드는 "TSized일 수도 아닐 수도 있다"를 뜻하며, 이 표기는 제네릭 타입이 컴파일 시점에 크기가 알려져야 한다는 기본 규칙을 덮어써요. 이런 의미의 ?Trait 문법은 Sized에서만 사용할 수 있고, 다른 트레이트에서는 쓸 수 없어요.

또한 t 파라미터의 타입을 T에서 &T로 바꿨다는 점도 주목하세요. 타입이 Sized가 아닐 수 있으므로, 어떤 종류의 포인터 뒤에 놓아야 해요. 여기서는 참조를 선택했죠.

다음으로는 함수와 클로저에 대해 이야기할게요!

더 알아보기 (Learn more)