본문 바로가기

고급 타입

뉴타입과 타입 별칭의 목적을 구분하고 절대 반환하지 않는 타입과 동적 크기 타입·Sized 제약을 이해합니다.

러스트의 타입 시스템에는 지금까지 이야기했지만 아직 다루지 않은 기능이 몇 가지 있습니다. 먼저 뉴타입이 왜 타입으로서 쓸모 있는지를 살피며 뉴타입을 일반적으로 이야기합니다. 그다음 뉴타입과 비슷하지만 의미가 조금 다른 기능인 타입 별칭으로 넘어갑니다. ! 타입과 동적 크기 타입도 이야기합니다.

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

이 절은 앞 글의 뉴타입 패턴을 사용하여 외부 타입에 외부 트레이트 구현하기를 읽었다고 가정합니다. 뉴타입 패턴은 지금까지 이야기한 것 말고도 다른 일에 쓸모가 있습니다. 값이 결코 헷갈리지 않도록 정적으로 강제하는 것과, 값의 단위를 나타내는 것이 그것입니다. 앞 글의 Millimeters와 Meters 구조체가 뉴타입으로 단위를 나타낸 예입니다. 이 구조체들은 u32 값을 뉴타입으로 감쌌습니다. Millimeters 타입의 매개변수를 받는 함수를 썼다면, 실수로 Meters 타입의 값이나 그냥 u32로 그 함수를 부르려는 프로그램은 컴파일할 수 없을 것입니다.

뉴타입 패턴으로 어떤 타입의 구현 세부 사항을 추상화할 수도 있습니다. 새 타입은 비공개인 안쪽 타입의 API와 다른 공개 API를 노출할 수 있습니다.

뉴타입은 내부 구현을 숨길 수도 있습니다. 예를 들어 사람의 ID와 이름을 연결해 저장하는 HashMap<i32, String>을 감싼 People 타입을 제공할 수 있습니다. People을 쓰는 코드는 People 컬렉션에 이름 문자열을 더하는 메서드처럼 우리가 제공하는 공개 API로만 상호작용합니다. 그 코드는 내부적으로 이름에 i32 ID를 붙인다는 것을 알 필요가 없습니다. 뉴타입 패턴은 구현 세부 사항을 숨기는 캡슐화를 이루는 가벼운 방법이며, 캡슐화는 객체 지향 언어의 특성에서 이야기했습니다.

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

러스트는 기존 타입에 다른 이름을 주는 타입 별칭(type alias)을 선언하는 기능을 제공합니다. 이렇게 하려면 type 키워드를 씁니다. 예를 들어 다음처럼 i32의 별칭 Kilometers를 만들 수 있습니다.

type Kilometers = i32;

이제 별칭 Kilometers는 i32의 동의어입니다. 앞의 Millimeters와 Meters 타입과 달리 Kilometers는 따로 된 새 타입이 아닙니다. Kilometers 타입을 가진 값은 i32 타입의 값과 똑같이 다뤄집니다.

type Kilometers = i32;

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

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

Kilometers와 i32는 같은 타입이므로 두 타입의 값을 더할 수 있고, Kilometers 값을 i32 매개변수를 받는 함수에 넘길 수 있습니다. 이 코드는 x + y = 10을 출력합니다. 하지만 이 방법으로는 앞에서 이야기한 뉴타입 패턴의 타입 검사 이점을 얻지 못합니다. 다시 말해 Kilometers와 i32 값을 어딘가에서 헷갈려도, 컴파일러는 오류를 내지 않습니다.

타입 동의어를 쓰는 주된 경우는 반복을 줄이는 것입니다. 예를 들어 다음처럼 긴 타입이 있을 수 있습니다.

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

이 긴 타입을 함수 시그니처와 타입 표시에 코드 곳곳에서 써야 한다면, 지루하고 실수하기 쉬울 것입니다. 다음 코드처럼 그런 코드로 가득한 프로젝트를 상상해 보세요.

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> {
    // --생략--
}

타입 별칭은 반복을 줄여 이 코드를 더 관리하기 쉽게 만듭니다. 다음 코드에서는 장황한 타입에 Thunk라는 별칭을 도입하고, 그 타입이 쓰인 모든 곳을 더 짧은 별칭 Thunk로 바꿉니다.

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 {
    // --생략--
}

이 코드가 훨씬 읽고 쓰기 쉽습니다. 타입 별칭에 의미 있는 이름을 고르면 의도를 전하는 데도 도움이 됩니다. 썽크(thunk)는 나중에 평가할 코드를 가리키는 말이므로, 저장해 둘 클로저에 알맞은 이름입니다.

타입 별칭은 반복을 줄이려고 Result<T, E> 타입과 함께도 흔히 쓰입니다. 표준 라이브러리의 std::io 모듈을 봅니다. 입출력 연산은 연산이 동작하지 않는 상황을 다루려고 흔히 Result<T, E>를 돌려줍니다. 이 라이브러리에는 가능한 모든 입출력 오류를 나타내는 std::io::Error 구조체가 있습니다. std::io의 많은 함수는 E가 std::io::Error인 Result<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에는 다음 타입 별칭 선언이 있습니다.

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

이 선언은 std::io 모듈 안에 있으므로, 완전 정규화된 별칭 std::io::Result<T>를 쓸 수 있습니다. 곧 E가 std::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: fmt::Arguments) -> Result<()>;
}

타입 별칭은 두 가지로 도움이 됩니다. 코드를 쓰기 쉽게 하고, std::io 전체에 걸쳐 일관된 인터페이스를 줍니다. 별칭이므로 그냥 또 하나의 Result<T, E>일 뿐이며, Result<T, E>에 동작하는 어떤 메서드든 쓸 수 있고 ? 연산자 같은 특별한 문법도 쓸 수 있습니다.

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

러스트에는 !라는 특별한 타입이 있으며, 값이 없으므로 타입 이론의 용어로 빈 타입(empty type)이라고 합니다. 함수가 결코 돌아오지 않을 때 반환 타입의 자리에 서므로, never 타입이라고 부르는 것을 더 좋아합니다. 다음은 예입니다.

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

이 코드는 "함수 bar는 결코 돌아오지 않는다"라고 읽습니다. 결코 돌아오지 않는 함수를 발산 함수(diverging function)라고 부릅니다. ! 타입의 값은 만들 수 없으므로, bar는 결코 돌아올 수 없습니다.

만들 수 없는 타입이 무슨 쓸모가 있을까요? 숫자 맞히기 게임의 일부였던 추리 게임의 코드를 떠올려 보세요. 그 일부를 다음에 다시 옮겼습니다.

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

그때는 이 코드의 몇 가지 세부 사항을 건너뛰었습니다. 제어 흐름문에서 if의 모든 갈래는 같은 타입의 값이어야 한다고 이야기했는데, match의 갈래도 마찬가지로 모두 같은 타입을 돌려주어야 합니다. 그래서 예를 들어 다음 코드는 동작하지 않습니다.

src/main.rs (컴파일되지 않음)
let guess = match guess.trim().parse() {
    Ok(_) => 5,
    Err(_) => "hello",
};

이 코드에서 guess의 타입은 정수이면서 문자열이어야 할 텐데, 러스트는 guess가 하나의 타입만 가지도록 요구합니다. 그렇다면 continue는 무엇을 돌려줄까요? 앞의 코드에서 한 갈래는 u32를 돌려주고 다른 갈래는 continue로 끝나는 것이 어떻게 허용되었을까요?

짐작했겠지만, continue는 ! 값을 가집니다. 곧 러스트가 guess의 타입을 계산할 때 두 match 갈래를 모두 보는데, 앞의 것은 u32 값이고 뒤의 것은 ! 값입니다. !는 결코 값을 가질 수 없으므로, 러스트는 guess의 타입을 u32로 정합니다.

이 동작을 형식적으로 말하면, ! 타입의 표현식은 다른 어떤 타입으로든 강제 변환될 수 있다는 것입니다. 이 match 갈래를 continue로 끝내도 되는 까닭은, continue가 값을 돌려주지 않기 때문입니다. 대신 제어를 루프의 맨 위로 옮기므로, Err인 경우에는 guess에 값을 대입하지 않습니다.

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

이 코드에서도 앞의 match에서와 같은 일이 일어납니다. 러스트는 val이 T 타입이고 panic!이 ! 타입이라는 것을 보고, 전체 match 표현식의 결과를 T로 정합니다. panic!은 값을 만들지 않고 프로그램을 끝내므로 이 코드는 동작합니다. None인 경우에는 unwrap에서 값을 돌려주지 않으므로 이 코드는 유효합니다.

! 타입을 가진 마지막 표현식은 loop입니다.

print!("forever ");

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

여기서는 루프가 결코 끝나지 않으므로 !가 표현식의 값입니다. 하지만 break를 넣었다면 그렇지 않을 것입니다. 루프는 break에 이르면 끝나기 때문입니다.

더 깊이: !가 다른 타입으로 정해지지 않을 때

! 타입의 식은 어떤 타입으로든 바뀔 수 있으므로, 주변 코드가 그 타입을 정해 주지 않는 경우가 생깁니다. 그럴 때 컴파일러가 대신 고르는 타입을 대체 타입(fallback)이라고 하며, 에디션 가이드에 따르면 이 타입은 오래 ()였습니다. 그래서 컴파일러가 따로 ()를 추론할 까닭이 없는데도 !가 저절로 ()가 되는 일이 있었습니다. 2024 에디션부터는 대체 타입이 ! 자체이며, 가이드는 앞으로 모든 에디션에서 이렇게 바꿀 계획이라고 밝힙니다.

이 변경으로 동작이 달라지는 흔한 예는, 성공 값의 타입이 제네릭인 함수를 f()?;처럼 불러 결과를 버리는 경우입니다. 2024 에디션 전에는 이때 성공 값의 타입이 ()로 추론되었지만, 2024 에디션에서는 !로 추론되어 T: Default 같은 바운드를 만족하지 못하면 컴파일 오류가 납니다. 고치려면 f::<()>()?;처럼 타입을 직접 적습니다. 또 2024 에디션에서는 이 대체 타입이 unsafe 코드로 흘러 들어가는 경우를 알리는 never_type_fallback_flowing_into_unsafe 린트가 경고에서 오류로 올라갔습니다.

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

러스트는 특정 타입의 값에 공간을 얼마나 할당할지 같은, 타입에 관한 몇 가지 세부 사항을 알아야 합니다. 그래서 처음에는 타입 시스템의 한 구석이 조금 헷갈릴 수 있습니다. 동적 크기 타입(dynamically sized type)이라는 개념입니다. DST나 크기가 정해지지 않은 타입(unsized type)이라고도 부르며, 이 타입들은 런타임에만 크기를 알 수 있는 값을 쓰는 코드를 쓸 수 있게 해 줍니다.

이 교재 내내 써 온 str이라는 동적 크기 타입의 세부 사항을 파헤쳐 봅니다. &str이 아니라 str 자체가 DST입니다. 많은 경우, 예를 들어 사용자가 입력한 텍스트를 저장할 때, 문자열이 얼마나 길지는 런타임이 되어야 압니다. 그래서 str 타입의 변수를 만들 수도, str 타입의 인수를 받을 수도 없습니다. 다음 코드는 동작하지 않습니다.

src/main.rs (컴파일되지 않음)
let s1: str = "Hello there!";
let s2: str = "How's it going?";

러스트는 특정 타입의 값에 메모리를 얼마나 할당할지 알아야 하며, 한 타입의 모든 값은 같은 양의 메모리를 써야 합니다. 러스트가 이 코드를 쓰게 허용했다면, 두 str 값은 같은 양의 공간을 차지해야 할 것입니다. 하지만 두 값의 길이는 다릅니다. s1에는 12바이트의 저장 공간이, s2에는 15바이트가 필요합니다. 그래서 동적 크기 타입을 담는 변수를 만들 수 없습니다.

그렇다면 어떻게 해야 할까요? 이 경우에는 이미 답을 압니다. s1과 s2의 타입을 str 대신 &str로 만듭니다. 슬라이스에서 슬라이스 자료 구조는 슬라이스의 시작 위치와 길이만 저장한다고 했습니다. 그래서 &T는 T가 있는 메모리 주소를 저장하는 값 하나이지만, &str은 두 값입니다. str의 주소와 그 길이입니다. 그래서 &str 값의 크기는 컴파일 시점에 알 수 있습니다. usize 길이의 두 배입니다. 곧 &str이 가리키는 문자열이 아무리 길어도, &str의 크기는 언제나 압니다. 일반적으로 러스트에서 동적 크기 타입은 이런 방식으로 쓰입니다. 동적인 정보의 크기를 저장하는 추가 메타데이터를 가집니다. 동적 크기 타입의 황금률은, 동적 크기 타입의 값은 언제나 어떤 종류의 포인터 뒤에 두어야 한다는 것입니다.

str을 모든 종류의 포인터와 함께 쓸 수 있습니다. 예를 들어 Box<str>이나 Rc<str>이 있습니다. 사실 앞에서 다른 동적 크기 타입인 트레이트에서 이것을 보았습니다. 모든 트레이트는 그 트레이트의 이름으로 가리킬 수 있는 동적 크기 타입입니다. 트레이트 객체와 다형성에서, 트레이트를 트레이트 객체로 쓰려면 &dyn Trait이나 Box<dyn Trait>처럼 포인터 뒤에 두어야 한다고 했습니다. Rc<dyn Trait>도 됩니다. 아래 그림은 포인터가 가리키는 대상에 따라 어떤 메타데이터를 함께 가지는지 보여 줍니다.

RUST · ARCHITECTURE

DST를 가리킬 때는 주소와 해석 정보가 필요하다

기본 T: Sized는 크기를 아는 타입을 요구합니다. T: ?Sized는 그 기본 제한만 완화하며, DST는 포인터 메타데이터로 해석합니다.

동적 크기 타입과 포인터 메타데이터 기본 T: Sized와 이를 완화하는 T: ?Sized를 구분하고, &str 또는 &[T], &dyn Trait을 길이 또는 vtable 메타데이터 의미와 연결합니다. POINTER + METADATA POINTEE SEMANTICS 주소로 대상 접근 길이로 범위 해석 vtable로 동작 선택 &T · T: Sized주소 · 별도 DST 정보 없음 &str / &[T]주소 + 길이 의미의 metadata &dyn Trait주소 + vtable 의미의 metadata 크기를 아는 값compile time에 Sized 문자열 · 슬라이스 데이터DST는 포인터 뒤에 존재 구체 값 + 동적 동작trait object · runtime dispatch T: Sized · 기본 고정 크기 제한 T: ?Sized · 기본 제한 완화
세 포인터와 대상의 메타데이터 대응 Sized 포인터는 크기를 아는 값에, 문자열·슬라이스 포인터는 길이로 해석할 데이터에, 트레이트 객체 포인터는 vtable로 선택할 동작에 각각 연결되며 세 대응은 독립적입니다. POINTER +METADATA POINTEESEMANTICS 주소로 대상 접근 길이로 범위 해석 vtable로 동작 선택 &T ·T: Sized주소 ·별도 DST정보 없음 크기를아는 값compiletime에Sized &str /&[T]주소 +길이의미의metadata 문자열 ·슬라이스데이터DST는포인터뒤에 존재 &dynTrait주소 +vtable의미의metadata 구체 값 +동적 동작traitobject ·runtimedispatch T: Sized ·기본 고정크기 제한 T:?Sized ·기본 제한완화

&T · T: Sized → 크기를 아는 값

주소로 대상에 접근하며 별도 DST 메타데이터가 필요하지 않습니다.

&str / &[T] → 문자열·슬라이스

str은 바이트 길이, [T]는 요소 수라는 길이 의미를 포인터 메타데이터로 운반합니다.

&dyn Trait → trait object

구체 값 주소와 vtable 의미가 동적 dispatch를 가능하게 합니다.

정확한 ABI word 수를 모든 문맥의 안정 보장으로 일반화하지 않습니다. T: ?Sized는 기본 Sized 제한만 완화하며 DST의 by-value 저장을 허용하지 않습니다.

&str · 길이의 단위는 바이트

let s = "가A";
assert_eq!(s.len(), 4);

UTF-8의 가는 3바이트, A는 1바이트입니다. 메타데이터의 길이는 글자 수 2가 아닌 바이트 수 4입니다.

&[T] · 길이의 단위는 요소 수

let s = &[10, 20, 30];
assert_eq!(s.len(), 3);

포인터는 시작 요소를 가리키고 길이는 요소 3개를 나타냅니다. 전체 바이트 수와는 다른 값입니다.

?Sized는 기본 Sized 바운드를 완화한다

fn inspect<T: ?Sized>(t: &T) {
    // T가 str이나 dyn Trait일 수도 있음
}

str, [T], dyn Trait 자체는 DST이지만 이를 가리키는 참조나 Box는 크기를 알 수 있습니다. 그림은 메타데이터의 역할을 설명하며 물리적인 ABI 배치와 바이트 수를 지정하지 않습니다.

  • Sized: 주소로 고정 크기 대상 접근
  • DST: 길이 또는 vtable 의미가 필요
  • T: ?Sized는 Sized 기본 제한만 완화 · by-value는 별도 경계

Sized 값과 ?Sized 제한 완화를 구분하고, 문자열·슬라이스 DST와 trait object를 길이·vtable 메타데이터 의미에 대응시킵니다.

크기를 아는 T를 가리키는 &T는 주소만으로 대상에 접근하며 따로 메타데이터가 필요 없습니다. &str과 &[T]는 주소에 더해 길이를 뜻하는 메타데이터를 가지며, str의 길이는 바이트 수이고 [T]의 길이는 원소의 수입니다. 그래서 let s = "가A";의 s.len()은 글자 수 2가 아니라 바이트 수 4입니다. UTF-8에서 가는 3바이트, A는 1바이트이기 때문입니다. &[10, 20, 30]의 길이는 원소 3개를 나타내며 전체 바이트 수와는 다릅니다. &dyn Trait은 구체적인 값의 주소에 더해 vtable을 뜻하는 메타데이터를 가지며, 이것으로 동적 디스패치를 합니다. 그림은 메타데이터의 역할을 설명할 뿐, 메모리에서 정확히 몇 워드로 배치되는지를 모든 경우에 보장하지는 않습니다.

DST를 다루기 위해, 러스트는 컴파일 시점에 타입의 크기를 아는지 판단하는 Sized 트레이트를 제공합니다. 이 트레이트는 컴파일 시점에 크기를 아는 모든 것에 자동으로 구현됩니다. 또 러스트는 모든 제네릭 함수에 Sized 바운드를 암묵적으로 더합니다. 곧 다음과 같은 제네릭 함수 정의는

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

실제로 다음처럼 쓴 것처럼 다뤄집니다. 아래의 T: Sized는 직접 적지 않아도 컴파일러가 모든 제네릭 타입 매개변수에 저절로 더하는 바운드입니다.

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

기본으로 제네릭 함수는 컴파일 시점에 크기를 아는 타입에만 동작합니다. 하지만 다음 특별한 문법으로 이 제한을 완화할 수 있습니다.

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

?Sized의 트레이트 바운드는 "T는 Sized일 수도 아닐 수도 있다"라는 뜻이며, 이 표기는 제네릭 타입이 컴파일 시점에 크기를 아는 타입이어야 한다는 기본값을 덮어씁니다. 이런 뜻을 가진 ?Trait 문법은 Sized에만 쓸 수 있고, 다른 트레이트에는 쓸 수 없습니다.

또 t 매개변수의 타입을 T에서 &T로 바꾼 점을 보세요. 타입이 Sized가 아닐 수 있으므로, 어떤 종류의 포인터 뒤에서 써야 합니다. 이 경우에는 참조를 골랐습니다. T: ?Sized는 기본 Sized 제한만 완화할 뿐, 동적 크기 타입의 값을 포인터 없이 그대로 저장하게 해 주지는 않습니다. str, [T], dyn Trait 자체는 DST이지만, 그것을 가리키는 참조나 Box는 크기를 알 수 있습니다.

다음으로 함수와 클로저를 이야기합니다.

정리

  • 뉴타입은 단위가 다른 값을 헷갈리지 않게 막고, 안쪽 타입의 구현을 숨긴 채 다른 공개 API를 줄 수 있습니다.
  • 타입 별칭 type Kilometers = i32;는 새 타입이 아니라 같은 타입의 다른 이름이므로 타입 검사의 이점은 없지만, Thunk나 io::Result<T>처럼 긴 타입의 반복을 줄입니다.
  • never 타입 !는 값이 없는 타입으로, continue, panic!, 끝나지 않는 loop가 이 타입을 가지며 다른 어떤 타입으로든 강제 변환됩니다.
  • str, [T], dyn Trait 같은 동적 크기 타입은 포인터 뒤에 두어야 하며, 포인터는 길이나 vtable 같은 메타데이터를 함께 가집니다.
  • 제네릭은 기본으로 T: Sized이며, T: ?Sized로 완화할 수 있지만 그때는 &T처럼 포인터 뒤에서 씁니다.

연습 문제

1. 별칭일까 뉴타입일까

(가) 함수 시그니처에 자주 나오는 HashMap<String, Vec<(u32, String)>>을 짧게 쓰고 싶다. (나) 사용자 ID와 주문 ID가 둘 다 u64인데, 서로 바꿔 넘기는 실수를 컴파일러가 막아 주면 좋겠다. 각각 타입 별칭과 뉴타입 가운데 무엇이 알맞나요?

해설 보기

(가)는 같은 타입을 짧게 부르기만 하면 되므로 type Index = HashMap<String, Vec<(u32, String)>>; 같은 타입 별칭이 알맞습니다. (나)는 두 값이 서로 다른 타입이어야 실수를 막을 수 있으므로 struct UserId(u64);와 struct OrderId(u64); 같은 뉴타입을 씁니다. 타입 별칭으로 만들면 둘 다 그냥 u64라 바꿔 넘겨도 컴파일됩니다.

2. 길이와 타입

let s: &str = "héllo";에서 s.len()은 얼마인가요? fn show<T>(x: &T)에 show(s)를 넘기면 T는 무엇이 되고, fn show<T>(x: T)에 *s를 넘기면 어떻게 되나요?

해설 보기

é는 UTF-8에서 2바이트이므로 s.len()은 글자 수 5가 아니라 바이트 수 6입니다. show<T>(x: &T)에 s를 넘기면 T는 str이 되어야 하는데, 기본 바운드 T: Sized를 str이 만족하지 않으므로 컴파일되지 않으며, fn show<T: ?Sized>(x: &T)로 바꿔야 합니다. show<T>(x: T)에 *s를 넘기는 것은 str 값을 포인터 없이 그대로 넘기려는 것이므로 언제나 컴파일되지 않습니다.

다음 글 고급 함수와 클로저에서는 함수 포인터와 클로저를 돌려주는 방법을 살펴봅니다.