본문 바로가기

고급 트레이트

연관 타입·기본 제네릭·연산자 오버로딩·완전 정규화·슈퍼트레이트·뉴타입으로 트레이트 API의 제약을 정교하게 설계합니다.

트레이트는 트레이트와 공통 동작에서 처음 다루었지만, 더 고급의 세부 사항은 이야기하지 않았습니다. 이제 러스트에 대해 더 알게 되었으니, 핵심으로 들어가 봅니다.

연관 타입으로 트레이트 정의에서 자리표시자 타입 지정하기

연관 타입(associated type)은 타입 자리 표시자를 트레이트와 이어, 트레이트 메서드의 정의가 시그니처에서 이 자리 표시자 타입을 쓸 수 있게 합니다. 트레이트를 구현하는 쪽은 그 구현에서 자리 표시자 타입 대신 쓸 구체적인 타입을 지정합니다. 그러면 트레이트를 구현하기 전까지는 그 타입이 정확히 무엇인지 몰라도, 어떤 타입을 쓰는 트레이트를 정의할 수 있습니다.

이번 장의 고급 기능 대부분은 드물게 필요하다고 했습니다. 연관 타입은 그 중간쯤입니다. 이 교재의 나머지에서 설명한 기능보다는 드물게 쓰이지만, 이번 장에서 이야기하는 다른 많은 기능보다는 더 흔히 쓰입니다.

연관 타입을 가진 트레이트의 한 예가 표준 라이브러리가 제공하는 Iterator 트레이트입니다. 연관 타입의 이름은 Item이며, Iterator 트레이트를 구현하는 타입이 반복하는 값의 타입을 대신합니다. Iterator 트레이트의 정의는 다음과 같습니다.

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

Item 타입은 자리 표시자이며, next 메서드의 정의는 Option<Self::Item> 타입의 값을 돌려준다고 보여 줍니다. Iterator 트레이트를 구현하는 쪽은 Item의 구체적인 타입을 지정하고, next 메서드는 그 구체적인 타입의 값을 담은 Option을 돌려줍니다.

연관 타입은 제네릭과 비슷한 개념으로 보일 수 있습니다. 제네릭도 어떤 타입을 다룰 수 있는지 지정하지 않고 함수를 정의할 수 있게 하기 때문입니다. 두 개념의 차이를 보기 위해, Item 타입을 u32로 지정하는 Counter라는 타입에 대한 Iterator 트레이트의 구현을 봅니다.

src/lib.rs
struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --생략--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

이 문법은 제네릭의 문법과 비슷해 보입니다. 그렇다면 다음처럼 제네릭으로 Iterator 트레이트를 정의하지 않은 까닭은 무엇일까요?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}

차이는, 이렇게 제네릭을 쓰면 각 구현에서 타입을 표시해야 한다는 것입니다. Iterator<String> for Counter나 다른 어떤 타입에 대해서도 구현할 수 있으므로, Counter에 대한 Iterator의 구현이 여러 개 있을 수 있습니다. 다시 말해 트레이트에 제네릭 매개변수가 있으면, 한 타입에 대해 제네릭 타입 매개변수의 구체적인 타입을 매번 바꿔 가며 여러 번 구현할 수 있습니다. Counter에 next 메서드를 쓸 때, 어느 Iterator의 구현을 쓰고 싶은지 나타내려면 타입 표시를 해야 할 것입니다.

연관 타입에서는 타입 표시를 하지 않아도 됩니다. 한 타입에 트레이트를 여러 번 구현할 수 없기 때문입니다. 연관 타입을 쓰는 정의에서는 Item의 타입을 한 번만 고를 수 있습니다. impl Iterator for Counter는 하나만 있을 수 있기 때문입니다. Counter에 next를 부르는 모든 곳에서 u32 값의 반복자를 원한다고 지정할 필요가 없습니다.

연관 타입은 트레이트의 약속의 일부도 됩니다. 트레이트를 구현하는 쪽은 연관 타입의 자리 표시자에 들어갈 타입을 제공해야 합니다. 연관 타입에는 흔히 그 타입이 어떻게 쓰일지를 나타내는 이름이 붙으며, API 문서에서 연관 타입을 문서화하는 것은 좋은 관행입니다. 아래 그림은 이 글에서 다룰 트레이트 도구들이 각각 무엇을 정하는지 나란히 보여 줍니다.

RUST · 타입 계약

고급 트레이트 도구는 서로 다른 계약을 표현한다

연관 타입·기본 제네릭·슈퍼트레이트·뉴타입은 필요에 따라 함께 사용할 수 있습니다.

연관 타입 · 구현이 정하는 결과 타입

impl Iterator for Counter {
    type Item = u32;
    // next는 Option<u32>를 반환
}

이 Iterator 구현의 Item은 u32 하나로 정해집니다. 별도 타입 인자를 가진 트레이트는 그 인자에 따라 다른 구현을 가질 수 있습니다.

기본 제네릭 · 흔한 인자 타입 생략

trait Add<Rhs = Self> {
    type Output;
    // add(self, rhs: Rhs)
}

Rhs를 생략하면 Self를 사용합니다. Add<Meters> for Millimeters처럼 다른 오른쪽 타입을 지정할 수도 있습니다.

슈퍼트레이트 · 다른 기능도 요구

trait OutlinePrint: Display {
    // Display를 이용해 출력
}

OutlinePrint 구현 타입은 Display도 구현해야 합니다. 슈퍼트레이트 선언만으로 Display 구현이 자동 생성되지는 않습니다.

뉴타입 · 로컬 타입에 구현

struct Wrapper(Vec<String>);
impl Display for Wrapper {
    // self.0을 포맷
}

외부 Vec에 외부 Display를 직접 구현하는 대신 로컬 Wrapper에 구현합니다. Wrapper는 Vec과 다른 타입이므로 내부 메서드의 노출 방식도 정합니다.

한 도구를 고르면 다른 도구를 버리는 것이 아니다

본문의 Add<Rhs = Self>는 기본 제네릭 인자 Rhs와 연관 타입 Output을 함께 사용합니다. 뉴타입에 Add를 구현하거나 슈퍼트레이트와 연관 타입을 함께 두는 설계도 가능합니다.

코드는 핵심 선언 발췌이며 메서드 본문과 import를 생략했습니다. 각 도구가 고정하는 것이 구현의 결과 타입인지, 타입 인자의 기본값인지, 필수 동작인지, 로컬 타입 경계인지 구분합니다.

연관 타입은 impl Iterator for Counter에서 type Item = u32;처럼 구현이 결과 타입 하나를 정합니다. 별도의 타입 인자를 가진 트레이트라면 그 인자에 따라 구현이 여럿일 수 있습니다. 기본 제네릭 타입 매개변수는 trait Add<Rhs = Self>처럼 흔한 인자 타입을 생략하게 해 주며, Add<Meters> for Millimeters처럼 다른 오른쪽 타입을 지정할 수도 있습니다. 슈퍼트레이트는 trait OutlinePrint: Display처럼 구현하는 타입이 다른 트레이트도 구현하라고 요구하며, 이 선언만으로 Display 구현이 저절로 생기지는 않습니다. 뉴타입은 struct Wrapper(Vec<String>);처럼 외부 타입을 지역 타입으로 감싸 외부 트레이트를 구현하게 해 주며, Wrapper는 Vec과 다른 타입이므로 안쪽 메서드를 어떻게 드러낼지도 정해야 합니다. 이 도구들은 하나를 고르면 다른 것을 버려야 하는 관계가 아니며, Add<Rhs = Self>처럼 기본 제네릭 인자와 연관 타입 Output을 함께 쓰기도 합니다.

기본 제네릭 타입 매개변수와 연산자 오버로딩

제네릭 타입 매개변수를 쓸 때, 그 제네릭 타입에 대한 기본 구체 타입을 지정할 수 있습니다. 그러면 기본 타입으로 충분할 때 트레이트를 구현하는 쪽이 구체적인 타입을 지정하지 않아도 됩니다. 제네릭 타입을 선언할 때 <PlaceholderType=ConcreteType> 문법으로 기본 타입을 지정합니다.

이 기법이 쓸모 있는 좋은 예는 연산자 오버로딩(operator overloading)입니다. 연산자 오버로딩은 특정 상황에서 + 같은 연산자의 동작을 바꾸는 것입니다.

러스트에서는 직접 연산자를 만들거나 임의의 연산자를 오버로딩할 수 없습니다. 하지만 std::ops에 나열된 연산자와 그에 대응하는 트레이트는, 그 연산자에 관련된 트레이트를 구현해 오버로딩할 수 있습니다. 예를 들어 다음 코드에서는 두 Point 인스턴스를 더하도록 + 연산자를 오버로딩합니다. Point 구조체에 Add 트레이트를 구현해 그렇게 합니다.

src/main.rs
use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}

add 메서드는 두 Point 인스턴스의 x 값을 더하고 y 값을 더해 새 Point를 만듭니다. Add 트레이트에는 add 메서드가 돌려주는 타입을 정하는 Output이라는 연관 타입이 있습니다.

이 코드의 기본 제네릭 타입은 Add 트레이트 안에 있습니다. 정의는 다음과 같습니다.

trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}

이 코드는 대체로 익숙해 보일 것입니다. 메서드 하나와 연관 타입 하나를 가진 트레이트입니다. 새 부분은 Rhs=Self로, 이 문법을 기본 타입 매개변수(default type parameter)라고 합니다. Rhs 제네릭 타입 매개변수(right-hand side의 줄임말)는 add 메서드의 rhs 매개변수의 타입을 정합니다. Add 트레이트를 구현할 때 Rhs의 구체적인 타입을 지정하지 않으면, Rhs의 타입은 기본으로 Add를 구현하는 타입인 Self가 됩니다.

Point에 Add를 구현할 때는 두 Point 인스턴스를 더하고 싶었으므로 Rhs의 기본값을 썼습니다. 기본값을 쓰지 않고 Rhs 타입을 바꾸고 싶은, Add 트레이트를 구현하는 예를 봅니다.

단위가 다른 값을 담는 두 구조체 Millimeters와 Meters가 있습니다. 기존 타입을 다른 구조체로 얇게 감싸는 것을 뉴타입 패턴(newtype pattern)이라고 하며, 이것은 뉴타입 패턴을 사용하여 외부 타입에 외부 트레이트 구현하기에서 더 자세히 이야기합니다. 밀리미터 값을 미터 값에 더하고, Add의 구현이 변환을 올바르게 하게 하고 싶습니다. 다음 코드처럼 Rhs를 Meters로 하여 Millimeters에 Add를 구현할 수 있습니다.

src/lib.rs
use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}

Millimeters와 Meters를 더하려고, 기본값 Self를 쓰는 대신 impl Add<Meters>로 Rhs 타입 매개변수의 값을 정했습니다.

기본 타입 매개변수는 주로 다음 두 가지에 씁니다.

쓰임
기존 코드를 깨뜨리지 않고 타입을 넓힙니다.
대부분의 사용자에게는 필요 없는 특정 경우에 맞춰 바꿀 수 있게 합니다.

표준 라이브러리의 Add 트레이트는 두 번째 목적의 예입니다. 대개는 같은 타입끼리 더하지만, Add 트레이트는 그 이상으로 바꿀 수 있는 능력을 줍니다. Add 트레이트의 정의에서 기본 타입 매개변수를 쓰면, 대부분의 경우 추가 매개변수를 지정하지 않아도 됩니다. 다시 말해 구현의 상투적인 코드가 조금 필요 없어져, 트레이트를 쓰기가 쉬워집니다.

첫 번째 목적은 두 번째와 비슷하지만 반대 방향입니다. 기존 트레이트에 타입 매개변수를 더하고 싶을 때, 기본값을 주면 기존 구현 코드를 깨뜨리지 않고 트레이트의 기능을 넓힐 수 있습니다.

모호성 방지를 위한 완전 정규화 문법: 같은 이름의 메서드 호출하기

러스트에서는 한 트레이트에 다른 트레이트의 메서드와 같은 이름의 메서드가 있는 것을 막지 않으며, 한 타입에 두 트레이트를 모두 구현하는 것도 막지 않습니다. 트레이트의 메서드와 같은 이름의 메서드를 타입에 직접 구현할 수도 있습니다.

같은 이름의 메서드를 부를 때는, 러스트에게 어느 것을 쓰고 싶은지 알려야 합니다. 다음 코드를 봅니다. 둘 다 fly라는 메서드를 가진 Pilot과 Wizard 두 트레이트를 정의했습니다. 그다음 이미 fly라는 메서드가 구현된 Human 타입에 두 트레이트를 모두 구현합니다. 각 fly 메서드는 서로 다른 일을 합니다.

src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

Human의 인스턴스에 fly를 부르면, 컴파일러는 기본으로 타입에 직접 구현된 메서드를 부릅니다. person.fly()는 *waving arms furiously*를 출력하며, 러스트가 Human에 직접 구현된 fly 메서드를 불렀다는 것을 보여 줍니다.

Pilot 트레이트나 Wizard 트레이트의 fly 메서드를 부르려면, 어느 fly 메서드를 뜻하는지 지정하는 더 명시적인 문법이 필요합니다. 다음 코드가 이 문법을 보여 줍니다.

src/main.rs
fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

메서드 이름 앞에 트레이트 이름을 지정하면, 러스트에게 어느 fly의 구현을 부르고 싶은지 분명히 알려 줍니다. Human::fly(&person)이라고 쓸 수도 있으며, 이것은 person.fly()와 같지만 모호하지 않게 하려는 것이 아니라면 쓰기가 조금 더 깁니다. 이 코드를 실행하면 다음과 같이 출력됩니다.

This is your captain speaking.
Up!
*waving arms furiously*

fly 메서드는 self 매개변수를 받으므로, 둘 다 하나의 트레이트를 구현하는 타입이 둘 있다면, 러스트는 self의 타입으로 어느 트레이트의 구현을 쓸지 알아낼 수 있습니다.

하지만 메서드가 아닌 연관 함수에는 self 매개변수가 없습니다. 메서드가 아닌 같은 이름의 함수를 정의한 타입이나 트레이트가 여럿이라면, 완전 정규화 문법(fully qualified syntax)을 쓰지 않는 한 러스트가 어느 타입을 뜻하는지 늘 알아낼 수 있는 것은 아닙니다. 예를 들어 다음 코드에서는 모든 강아지 이름을 Spot으로 짓고 싶은 동물 보호소를 위한 트레이트를 만듭니다. 메서드가 아닌 연관 함수 baby_name을 가진 Animal 트레이트를 만듭니다. Animal 트레이트는 Dog 구조체에 구현하며, Dog에도 메서드가 아닌 연관 함수 baby_name을 직접 제공합니다.

src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}

모든 강아지를 Spot으로 이름 짓는 코드는 Dog에 정의된 baby_name 연관 함수에 구현합니다. Dog 타입은 모든 동물이 가진 특성을 나타내는 Animal 트레이트도 구현합니다. 새끼 개는 퍼피(puppy)라고 부르며, 이것은 Dog에 대한 Animal 트레이트의 구현에서 Animal 트레이트에 연관된 baby_name 함수로 나타납니다.

main에서는 Dog::baby_name 함수를 부르며, 이것은 Dog에 직접 정의된 연관 함수를 부릅니다. 이 코드는 다음을 출력합니다.

A baby dog is called a Spot

이 출력은 우리가 원한 것이 아닙니다. Dog에 구현한 Animal 트레이트의 일부인 baby_name 함수를 불러, 코드가 A baby dog is called a puppy를 출력하게 하고 싶습니다. 앞에서 쓴, 트레이트 이름을 지정하는 기법은 여기서 도움이 되지 않습니다. main을 다음 코드로 바꾸면 컴파일 오류가 납니다.

src/main.rs (컴파일되지 않음)
fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}

Animal::baby_name에는 self 매개변수가 없고, Animal 트레이트를 구현하는 다른 타입이 있을 수 있으므로, 러스트는 어느 Animal::baby_name의 구현을 원하는지 알아낼 수 없습니다. 다음 컴파일러 오류가 납니다. 위치는 앞의 Animal과 Dog의 정의 아래에 이 main을 둔 파일 기준입니다.

error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
 2 |     fn baby_name() -> String;
   |     ------------------------- `Animal::baby_name` defined here
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
   |                                           +++++++       +

For more information about this error, try `rustc --explain E0790`.

모호함을 없애고, 다른 타입이 아니라 Dog에 대한 Animal의 구현을 쓰고 싶다고 러스트에게 알리려면 완전 정규화 문법을 써야 합니다. 다음 코드는 완전 정규화 문법을 쓰는 방법을 보여 줍니다.

src/main.rs
fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}

꺾쇠괄호 안에 타입 표시를 주어 러스트에게, 이 함수 호출에서는 Dog 타입을 Animal로 다루고 싶으므로 Dog에 구현된 Animal 트레이트의 baby_name 메서드를 부르고 싶다고 알립니다. 이제 이 코드는 원하던 것을 출력합니다.

A baby dog is called a puppy

일반적으로 완전 정규화 문법은 다음과 같이 정의됩니다. 꺾쇠괄호 안에서 as 앞은 구현한 타입이고 뒤는 그 구현이 속한 트레이트이며, 바로 앞의 <Dog as Animal>::baby_name()이 이 모양에 해당합니다.

<Type as Trait>::function(receiver_if_method, next_arg, ...);

메서드가 아닌 연관 함수에는 receiver가 없고, 다른 인수의 목록만 있습니다. 함수나 메서드를 부르는 모든 곳에서 완전 정규화 문법을 쓸 수 있습니다. 하지만 러스트가 프로그램의 다른 정보로 알아낼 수 있는 이 문법의 어느 부분이든 생략할 수 있습니다. 같은 이름을 쓰는 구현이 여럿이고, 러스트가 어느 구현을 부르고 싶은지 알아내는 데 도움이 필요할 때만 이 더 장황한 문법을 쓰면 됩니다. 아래 그림은 이 절의 예제들에서 호출 문법에 따라 어느 구현이 골라지는지 정리한 것입니다.

RUST · 호출과 결과 비교

타입 이름과 트레이트 이름으로 호출할 구현을 고른다

본문의 Human::fly와 Dog::baby_name 예제는 같은 이름이라도 호출 문법에 따라 다른 구현이 선택됩니다.

메서드 · receiver가 있다

let person = Human;
person.fly();

Human 자체 구현 → *waving arms furiously*

Pilot::fly(&person);

Human의 Pilot 구현 → This is your captain speaking.

Wizard::fly(&person);

Human의 Wizard 구현 → Up!

연관 함수 · receiver가 없다

Dog::baby_name();

Dog 자체 구현 → Spot

<Dog as Animal>::baby_name();

Dog의 Animal 구현 → puppy

Animal::baby_name();

본문 예제에서는 구현 타입이 정해지지 않아 E0790 오류가 납니다.

완전 정규화 문법

<Type as Trait>::function(...)

Type과 Trait을 함께 지정합니다. 메서드라면 receiver도 인수로 전달합니다. 예: <Human as Pilot>::fly(&person). 추론할 수 있는 부분은 생략할 수 있습니다.

이 비교는 본문의 구체 예제를 설명합니다. 점 메서드 호출의 전체 탐색 규칙을 단순한 우선순위 하나로 일반화하지 않습니다.

수신자(receiver)가 있는 메서드에서는 person.fly()가 Human 자체의 구현인 *waving arms furiously*를, Pilot::fly(&person)이 Human의 Pilot 구현인 This is your captain speaking.을, Wizard::fly(&person)이 Up!을 출력합니다. 수신자가 없는 연관 함수에서는 Dog::baby_name()이 Spot을, <Dog as Animal>::baby_name()이 puppy를 돌려주고, Animal::baby_name()은 구현 타입이 정해지지 않아 E0790 오류가 납니다. 완전 정규화 문법 <Type as Trait>::function(...)은 타입과 트레이트를 함께 지정하며, 메서드라면 <Human as Pilot>::fly(&person)처럼 수신자도 인수로 넘깁니다. 이 비교는 이 글의 예제를 설명할 뿐, 점 연산자로 메서드를 찾는 규칙 전체를 하나의 우선순위로 일반화한 것은 아닙니다.

슈퍼트레이트를 사용하여 한 트레이트에서 다른 트레이트의 기능을 요구하기

다른 트레이트에 기대는 트레이트 정의를 쓸 때가 있습니다. 어떤 타입이 첫 번째 트레이트를 구현하려면, 그 타입이 두 번째 트레이트도 구현하도록 요구하고 싶은 것입니다. 그러면 트레이트 정의가 두 번째 트레이트의 연관 아이템을 쓸 수 있습니다. 트레이트 정의가 기대는 트레이트를 그 트레이트의 슈퍼트레이트(supertrait)라고 부릅니다.

예를 들어 주어진 값을 별표로 둘러 출력하는 outline_print 메서드를 가진 OutlinePrint 트레이트를 만들고 싶다고 합시다. 곧 표준 라이브러리의 Display 트레이트를 구현해 (x, y)를 출력하는 Point 구조체가 있을 때, x가 1이고 y가 3인 Point 인스턴스에 outline_print를 부르면 다음을 출력해야 합니다.

**********
*        *
* (1, 3) *
*        *
**********

outline_print 메서드의 구현에서는 Display 트레이트의 기능을 쓰고 싶습니다. 그러므로 OutlinePrint 트레이트가 Display도 구현하고 OutlinePrint에 필요한 기능을 제공하는 타입에만 동작한다고 지정해야 합니다. 트레이트 정의에 OutlinePrint: Display라고 지정해 그렇게 할 수 있습니다. 이 기법은 트레이트에 트레이트 바운드를 더하는 것과 비슷합니다. 다음 코드는 OutlinePrint 트레이트의 구현입니다.

src/main.rs
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

OutlinePrint가 Display 트레이트를 요구한다고 지정했으므로, Display를 구현하는 모든 타입에 자동으로 구현되는 to_string 함수를 쓸 수 있습니다. 콜론을 더하고 트레이트 이름 뒤에 Display 트레이트를 지정하지 않은 채 to_string을 쓰려 했다면, 현재 스코프에서 &Self 타입에 대해 to_string이라는 메서드를 찾을 수 없다는 오류가 났을 것입니다.

Point 구조체처럼 Display를 구현하지 않는 타입에 OutlinePrint를 구현하려 하면 무슨 일이 일어나는지 봅니다.

src/main.rs (컴파일되지 않음)
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

Display가 필요한데 구현되어 있지 않다는 오류가 납니다. 같은 E0277 오류가 두 번 나오는데, 하나는 impl OutlinePrint for Point 줄을, 다른 하나는 main에서 outline_print를 부르는 줄을 가리킵니다.

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:23
   |
20 | impl OutlinePrint for Point {}
   |                       ^^^^^ unsatisfied trait bound
   |
help: the trait `std::fmt::Display` is not implemented for `Point`
  --> src/main.rs:15:1
   |
15 | struct Point {
   | ^^^^^^^^^^^^
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
 3 | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:24:7
   |
24 |     p.outline_print();
   |       ^^^^^^^^^^^^^ unsatisfied trait bound
   |
help: the trait `std::fmt::Display` is not implemented for `Point`
  --> src/main.rs:15:1
   |
15 | struct Point {
   | ^^^^^^^^^^^^
note: required by a bound in `OutlinePrint::outline_print`
  --> src/main.rs:3:21
   |
 3 | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
 4 |     fn outline_print(&self) {
   |        ------------- required by a bound in this associated function

For more information about this error, try `rustc --explain E0277`.

이것을 고치려면 Point에 Display를 구현해 OutlinePrint가 요구하는 제약을 만족시킵니다. fmt에서 write!로 쓴 (x, y) 모양의 글자가 그대로 to_string의 결과가 되므로, outline_print는 이 문자열의 길이로 별표 테두리의 너비를 정합니다.

src/main.rs
use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

그러면 Point에 OutlinePrint 트레이트를 구현하는 코드가 성공적으로 컴파일되고, Point 인스턴스에 outline_print를 불러 별표로 둘러싸 출력할 수 있습니다.

뉴타입 패턴을 사용하여 외부 타입에 외부 트레이트 구현하기

트레이트와 공통 동작에서, 고아 규칙(orphan rule)을 이야기했습니다. 이 규칙은 트레이트나 타입 가운데 적어도 하나가 우리 크레이트의 것일 때만, 그 타입에 그 트레이트를 구현할 수 있다고 말합니다. 뉴타입 패턴으로 이 제한을 우회할 수 있습니다. 뉴타입 패턴은 튜플 구조체로 새 타입을 만드는 것입니다. 튜플 구조체는 구조체 정의 및 인스턴스화에서 다루었습니다. 이 튜플 구조체는 필드 하나를 가지며, 트레이트를 구현하고 싶은 타입을 얇게 감쌉니다. 그러면 감싼 타입은 우리 크레이트의 것이 되고, 그 감싼 타입에 트레이트를 구현할 수 있습니다. 뉴타입(newtype)은 Haskell 프로그래밍 언어에서 온 용어입니다. 이 패턴을 쓴다고 런타임 성능 비용이 생기지는 않으며, 감싼 타입은 컴파일 시점에 사라집니다.

예를 들어 Vec<T>에 Display를 구현하고 싶다고 합시다. Display 트레이트와 Vec<T> 타입 모두 우리 크레이트 바깥에 정의되어 있으므로, 고아 규칙이 그것을 직접 하지 못하게 막습니다. Vec<T>의 인스턴스를 담은 Wrapper 구조체를 만들면, 다음 코드처럼 Wrapper에 Display를 구현하고 Vec<T> 값을 쓸 수 있습니다.

src/main.rs
use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {w}");
}
w = [hello, world]

Display의 구현은 self.0으로 안쪽의 Vec<T>에 접근합니다. Wrapper는 튜플 구조체이고 Vec<T>는 튜플의 인덱스 0에 있는 원소이기 때문입니다. 그다음 Wrapper에서 Display 트레이트의 기능을 쓸 수 있습니다.

이 기법을 쓰는 단점은, Wrapper가 새 타입이므로 그것이 담은 값의 메서드를 가지지 않는다는 것입니다. Wrapper를 Vec<T>와 똑같이 다룰 수 있도록, self.0에 위임하는 방식으로 Vec<T>의 모든 메서드를 Wrapper에 직접 구현해야 할 것입니다. 새 타입이 안쪽 타입의 모든 메서드를 가지기를 원한다면, Wrapper에 안쪽 타입을 돌려주는 Deref 트레이트를 구현하는 것이 해결책이 될 것입니다. Deref는 Deref와 역참조 강제 변환에서 이야기했습니다. Wrapper 타입이 안쪽 타입의 모든 메서드를 가지지 않기를 원한다면, 예를 들어 Wrapper 타입의 동작을 제한하려면, 원하는 메서드만 직접 구현해야 할 것입니다.

트레이트가 관련되지 않은 경우에도 뉴타입 패턴은 쓸모가 있습니다. 관심을 옮겨, 러스트의 타입 시스템과 상호작용하는 몇 가지 고급 방법을 봅니다.

더 깊이: 고아 규칙의 정확한 모양

트레이트나 타입 가운데 하나가 지금 크레이트에 있어야 한다는 설명은 고아 규칙을 줄인 것입니다. Reference는 impl<P1..=Pn> Trait<T1..=Tn> for T0가 허용되는 조건을 이렇게 정합니다. Trait가 지금 크레이트의 트레이트이거나, 그렇지 않다면 T0부터 Tn까지 가운데 적어도 하나가 지금 크레이트의 타입이어야 하고, 그 첫 번째 지역 타입 Ti 앞의 타입들에는 덮이지 않은(uncovered) 타입 매개변수가 나타나지 않아야 합니다. 덮이지 않았다는 것은 Vec<T>의 T처럼 다른 타입의 인수로 들어가 있지 않고 T 그대로 쓰였다는 뜻입니다.

이 규칙 덕분에 지역 타입을 트레이트의 타입 인수 자리에 두는 구현도 허용됩니다. 예를 들어 impl From<Meters> for Vec<i32>는 From과 Vec이 모두 바깥의 것이지만 T1 자리의 Meters가 지역 타입이고 그 앞의 Vec<i32>에 타입 매개변수가 없으므로 허용됩니다. 반면 impl<T> From<Meters> for T는 지역 타입 앞에 덮이지 않은 T가 오므로 거부됩니다. 또 &, &mut, Box, Pin은 근본 타입 생성자(fundamental type constructor)여서, T가 지역 타입이면 &T나 Box<T>도 지역 타입으로 봅니다. 그래서 바깥 트레이트를 Box<Wrapper>에 구현하는 것도 허용됩니다.

Reference는 이 규칙이 라이브러리가 자기 트레이트에 impl<T: Display> MyTrait for T 같은 새 구현을 더해도 그 라이브러리를 쓰는 쪽의 구현과 충돌하지 않게 해 준다고 설명합니다. 고아 구현을 막지 않는다면, 두 크레이트가 같은 타입에 같은 트레이트를 서로 다르게 구현해 의존성을 더하거나 올리는 것만으로 컴파일이 깨질 수 있기 때문입니다.

정리

  • 연관 타입 type Item;은 구현마다 하나의 구체적인 타입을 정하게 하므로, 한 타입에 트레이트를 여러 번 구현하지 않고도 쓰는 곳마다 타입을 표시할 필요가 없습니다.
  • trait Add<Rhs = Self>처럼 기본 타입 매개변수를 두면 흔한 경우에 타입 인자를 생략할 수 있고, impl Add<Meters> for Millimeters처럼 바꿀 수도 있습니다.
  • 같은 이름의 메서드는 Pilot::fly(&person)처럼 트레이트 이름으로, 수신자가 없는 연관 함수는 <Dog as Animal>::baby_name()처럼 완전 정규화 문법으로 고르며, 고르지 않으면 E0790 오류가 납니다.
  • trait OutlinePrint: Display처럼 슈퍼트레이트를 지정하면 그 트레이트의 기능을 쓸 수 있지만, 구현하는 타입이 슈퍼트레이트도 구현해야 합니다.
  • 뉴타입 패턴으로 외부 타입을 지역 타입으로 감싸면 고아 규칙을 우회해 외부 트레이트를 구현할 수 있으며, 안쪽 메서드는 위임하거나 Deref로 드러냅니다.

연습 문제

1. 어떤 구현이 불릴까

이 글의 Human에서 <Human as Wizard>::fly(&person);과 Human::fly(&person);은 각각 무엇을 출력하나요? Pilot::fly(&Human)은 컴파일되나요?

해설 보기

<Human as Wizard>::fly(&person)은 Human의 Wizard 구현을 불러 Up!을, Human::fly(&person)은 Human에 직접 정의한 메서드를 불러 *waving arms furiously*를 출력합니다. Pilot::fly(&Human)도 컴파일되며, Human은 필드가 없는 유닛 구조체라 Human 자체가 값이므로 그 참조를 수신자로 넘겨 This is your captain speaking.을 출력합니다.

2. 뉴타입으로 동작 제한하기

Vec<String>을 감싼 NonEmpty라는 뉴타입을 만들어, 원소를 더할 수는 있지만 빼서 비게 만들 수는 없게 하고 싶습니다. Deref를 구현해야 할까요?

해설 보기

Deref<Target = Vec<String>>만 구현하면 &Vec<String>의 읽기용 메서드만 드러나므로 pop이나 clear는 부를 수 없지만, DerefMut까지 구현하면 그 메서드들도 열려 제약이 깨집니다. 제약을 지키려면 DerefMut를 구현하지 않고, push 같은 허용할 메서드만 직접 구현하며, 생성 함수에서 처음부터 원소가 하나 이상 있도록 검사합니다. 읽기만 드러낼 생각이라면 Deref 대신 iter나 len처럼 필요한 메서드만 위임하는 편이 더 분명합니다.

다음 글 고급 타입에서는 뉴타입 패턴의 다른 쓰임과 타입 별칭, never 타입, 동적 크기 타입을 살펴봅니다.