Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

고급 트레이트

우리는 10장의 “트레이트로 공통 동작 정의하기” 절에서 트레이트를 처음 다뤘지만, 그때는 더 깊은 세부는 건드리지 않았습니다. 이제 여러분이 러스트를 더 많이 알게 되었으니, 그 세부 사항까지 파고들 수 있습니다.

연관 타입을 사용해 트레이트 정의하기

연관 타입(associated types) 은 트레이트 안에 타입 자리표시자를 연결해 두고, 그 트레이트 메서드 시그니처 안에서 그 자리표시자를 사용할 수 있게 합니다. 트레이트를 구현하는 쪽은, 자기 구현에서 그 자리표시자에 들어갈 구체 타입을 지정합니다. 그 덕분에 트레이트를 정의하는 시점에는 아직 정확히 어떤 타입이 들어올지 모르더라도, 그 타입을 사용하는 트레이트를 정의할 수 있습니다.

이 장에서 다루는 고급 기능들 가운데, 연관 타입은 가장 흔한 축에 속합니다. 이 책 나머지의 기능들보다야 덜 자주 쓰이지만, 이 장의 다른 기능들보다는 훨씬 더 많이 보게 됩니다.

연관 타입을 가진 대표적인 예가 표준 라이브러리의 Iterator 트레이트입니다. 이 트레이트의 연관 타입 이름은 Item 이고, Iterator 를 구현한 타입이 “무엇을 순회하고 있는지”를 나타냅니다. Iterator 트레이트 정의는 목록 20-13과 같습니다.

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}
Listing 20-13: 연관 타입 Item 을 가진 Iterator 트레이트 정의

Item 타입은 자리표시자이고, next 메서드 정의는 그것이 Option<Self::Item> 타입 값을 반환한다는 사실을 보여 줍니다. Iterator 를 구현하는 쪽은 Item 에 들어갈 구체 타입을 지정하고, 그러면 next 메서드는 그 구체 타입 값을 담은 Option 을 반환하게 됩니다.

연관 타입은, 어떤 함수가 다룰 수 있는 타입을 아직 구체적으로 적지 않는다는 점에서 제네릭과 비슷해 보일 수 있습니다. 이 둘의 차이를 보기 위해, Counter 라는 타입에 Iterator 를 구현하면서 Itemu32 로 지정한 예를 생각해 봅시다.

Filename: 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> {
        // --snip--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

이 문법은 제네릭과 꽤 비슷해 보입니다. 그렇다면 왜 Iterator 를 제네릭을 사용해 목록 20-14처럼 정의하지 않을까요?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}
Listing 20-14: 제네릭을 사용한 Iterator 트레이트의 가상 정의

차이는, 목록 20-14 같은 제네릭 정의를 사용하면 구현할 때마다 타입을 다시 명시해야 한다는 점입니다. 더 나아가 Counter 에 대해 Iterator<String> 을 구현할 수도 있고, 또 다른 타입으로도 얼마든지 여러 번 구현할 수 있습니다. 즉, 어떤 트레이트가 제네릭 매개변수를 가지면, 하나의 타입에 대해 그 트레이트를 여러 번 구현하면서 제네릭 자리에 들어가는 구체 타입을 매번 바꿀 수 있습니다. 그러면 Counter 에 대해 next 를 쓸 때마다 “지금은 어떤 Iterator 구현을 쓰는지” 타입 주석으로 다시 알려 주어야 합니다.

반면 연관 타입을 쓰면 그런 주석이 필요 없습니다. 연관 타입 방식에서는 하나의 타입에 대해 같은 트레이트를 여러 번 구현할 수 없기 때문입니다. 목록 20-13의 정의에서는 Counter 에 대해 impl Iterator for Counter 가 딱 하나뿐이므로, Item 타입도 단 한 번만 정하면 됩니다. 그래서 Counternext 를 쓸 때마다 “지금은 u32 반복자를 원한다”는 식의 추가 타입 주석이 전혀 필요 없습니다.

연관 타입은 트레이트 계약의 일부가 됩니다. 트레이트를 구현하는 쪽은 연관 타입 자리표시자에 어떤 구체 타입을 넣을지 반드시 정해야 합니다. 그리고 그 연관 타입이 어떻게 사용될 것인지를 설명하는 이름을 잘 붙이고, API 문서에도 설명해 두는 것이 좋은 습관입니다.

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

제네릭 타입 매개변수를 사용할 때, 그 제네릭 타입에 대한 기본 구체 타입도 지정할 수 있습니다. 이렇게 하면 기본 타입이 충분한 경우, 트레이트 구현자가 굳이 구체 타입을 다시 지정할 필요가 없어집니다. 이 문법은 <PlaceholderType = ConcreteType> 형태로 씁니다.

이 기법이 특히 유용한 대표 사례가 연산자 오버로딩(operator overloading) 입니다. 즉, + 같은 연산자가 특정 상황에서 어떻게 동작할지 사용자 정의하는 것입니다.

러스트는 여러분이 완전히 새로운 연산자를 만들거나 임의의 연산자를 마음대로 오버로드 하도록 허용하지는 않습니다. 하지만 std::ops 에 나열된 연산과 그에 대응하는 트레이트에 대해서는, 관련 트레이트를 구현함으로써 동작을 바꿀 수 있습니다. 예를 들어 목록 20-15에서는 Point 인스턴스 두 개를 더할 때 + 연산자가 작동하도록 Add 트레이트를 구현합니다.

Filename: 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 }
    );
}
Listing 20-15: Point 인스턴스에 대해 + 를 오버로드하기 위해 Add 트레이트 구현하기

add 메서드는 두 Pointx 값을 더하고, y 값도 더해 새 Point 를 만듭니다. Add 트레이트에는 Output 이라는 연관 타입이 있고, 이것이 add 메서드가 반환하는 타입을 결정합니다.

여기서 기본 제네릭 타입이 실제로 어떻게 등장하는지는 Add 트레이트 정의를 보면 알 수 있습니다.

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;

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

이 코드는 전반적으로 익숙할 것입니다. 메서드 하나와 연관 타입 하나를 가진 트레이트죠. 새로운 부분은 Rhs=Self 입니다. 이것이 바로 기본 타입 매개변수 문법입니다. Rhs 제네릭 타입 매개변수(“right-hand side” 의 약자)는 add 메서드의 rhs 매개변수 타입을 정의합니다. 만약 Add 트레이트를 구현할 때 Rhs 의 구체 타입을 직접 지정하지 않으면, 기본값으로 Self, 즉 지금 Add 를 구현하고 있는 타입이 들어갑니다.

우리가 Point 에 대해 Add 를 구현할 때는 Rhs 기본값을 그대로 사용했습니다. 왜냐하면 Point 두 개를 더하고 싶었기 때문입니다. 그렇다면 이번에는 Rhs 를 기본값 대신 다른 타입으로 지정하는 구현을 보겠습니다.

예를 들어 MillimetersMeters 라는 두 구조체가 서로 다른 단위의 값을 담고 있다고 합시다. 기존 타입을 새 구조체 하나로 감싸는 이런 얇은 포장 방식을 newtype 패턴 이라고 부르는데, 이는 뒤의 [“외부 트레이트를 newtype 패턴으로 구현하기”] newtype 절에서 더 자세히 설명합니다. 우리가 원하는 것은 밀리미터 값과 미터 값을 더했을 때, Add 구현이 내부적으로 단위를 올바르게 변환해 주는 것입니다. 이를 위해 목록 20-16처럼 Millimeters 에 대해 MetersRhs 로 한 Add 구현을 만들 수 있습니다.

Filename: 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))
    }
}
Listing 20-16: MillimetersMeters 를 더하기 위해 Add 트레이트 구현하기

여기서는 Add<Meters> 라고 적어 Rhs 타입 매개변수를 기본값 대신 Meters 로 지정했습니다.

기본 타입 매개변수는 주로 두 가지 방식으로 사용됩니다.

  1. 기존 코드를 깨뜨리지 않고 타입을 확장할 때
  2. 대부분의 사용자는 필요 없지만, 특정 경우에는 세밀한 커스터마이징을 허용하고 싶을 때

표준 라이브러리의 Add 트레이트는 두 번째 목적의 좋은 예입니다. 보통은 같은 타입 끼리 더하지만, Add 는 그보다 더 많은 경우를 표현할 수 있는 여지를 제공합니다. Add 정의에 기본 타입 매개변수를 두었기 때문에, 대부분의 경우 추가 타입 인수를 매번 적을 필요가 없습니다. 즉, 약간의 구현 보일러플레이트를 없애면서 트레이트 사용성을 높인 셈입니다.

첫 번째 목적은 그 반대 방향에서 비슷합니다. 이미 존재하는 트레이트에 타입 매개변수를 추가하고 싶다면, 기본값을 제공함으로써 기존 구현 코드를 깨지 않고도 기능을 확장할 수 있습니다.

이름이 같은 메서드 구분하기

러스트는 어떤 트레이트 메서드 이름이 다른 트레이트의 메서드 이름과 같다고 해서 막지 않습니다. 또한 하나의 타입에 두 트레이트를 모두 구현하는 것도 가능하고, 심지어 그 타입 자체에 같은 이름의 메서드를 직접 구현하는 것도 가능합니다.

이처럼 이름이 같은 메서드가 여러 개 있을 때는, 러스트에게 “정확히 어떤 메서드를 원하는지”를 더 분명히 알려 줘야 합니다. 목록 20-17에는 fly 라는 메서드를 가진 두 트레이트 Pilot, Wizard 와, fly 메서드를 직접 구현한 Human 타입이 등장합니다. 각 fly 는 서로 다른 일을 합니다.

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

fn main() {}
Listing 20-17: fly 메서드를 가진 두 트레이트를 Human 에 구현하고, Human 자체에도 fly 를 구현한 예

Human 인스턴스에 대해 그냥 fly 를 호출하면, 러스트는 기본적으로 타입에 직접 구현된 메서드를 호출합니다. 목록 20-18이 그 예입니다.

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

fn main() {
    let person = Human;
    person.fly();
}
Listing 20-18: Human 인스턴스에 대해 fly 호출하기

이 코드를 실행하면 *waving arms furiously* 가 출력됩니다. 즉 러스트는 Human 자체에 구현된 fly 메서드를 호출한 것입니다.

Pilot 이나 Wizard 트레이트에서 온 fly 메서드를 호출하려면, 더 명시적인 문법이 필요합니다. 목록 20-19가 그 방법을 보여 줍니다.

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

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}
Listing 20-19: 어느 트레이트의 fly 메서드를 호출할지 명시하기

메서드 이름 앞에 트레이트 이름을 붙이면, 러스트는 우리가 어떤 fly 구현을 원하는지 명확히 알 수 있습니다. 물론 person.fly() 대신 Human::fly(&person) 처럼 쓸 수도 있지만, 특별히 구분할 필요가 없다면 전자가 더 짧고 읽기 쉽습니다.

이 코드를 실행하면 다음과 같은 출력이 나옵니다.

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*

fly 메서드는 self 매개변수를 받기 때문에, 만약 두 타입 이 같은 트레이트 를 구현하고 있다면 러스트는 self 의 타입을 보고 어떤 구현을 써야 할지 결정할 수 있습니다.

하지만 메서드가 아닌 연관 함수는 self 매개변수가 없습니다. 이 경우에는, 같은 함수 이름을 가진 타입이나 트레이트가 여러 개 있을 때, 완전 수식 문법(fully qualified syntax)을 사용하지 않으면 러스트가 어느 쪽을 뜻하는지 알 수 없습니다. 예를 들어 목록 20-20에서는 동물 보호소를 위한 예를 만듭니다. 보호소는 모든 강아지 이름을 Spot 으로 부르고 싶어 한다고 합시다. 우리는 Animal 트레이트를 만들고, 여기에 메서드가 아닌 연관 함수 baby_name 을 둡니다. 그리고 Dog 구조체에도 baby_name 을 직접 구현하면서, 동시에 Animal 트레이트도 구현합니다.

Filename: 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());
}
Listing 20-20: 연관 함수를 가진 트레이트와, 같은 이름의 연관 함수를 직접 구현하고 동시에 그 트레이트도 구현하는 타입

여기서는 Dog 자체에 정의된 baby_name 연관 함수 안에서 모든 강아지를 Spot 이라고 부르게 했습니다. 동시에 Animal 트레이트도 Dog 에 대해 구현했는데, 이 트레이트에서 정의한 baby_name 은 “아기 개는 puppy라고 부른다”는 사실을 표현합니다.

main 에서 Dog::baby_name 을 호출하면, 이는 Dog 자체에 직접 정의된 연관 함수를 호출합니다. 그래서 출력은 다음과 같습니다.

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/traits-example`
A baby dog is called a Spot

하지만 우리가 진짜 호출하고 싶은 것은 Dog 에 대해 구현된 Animal 트레이트 안의 baby_name 이므로, 출력은 A baby dog is called a puppy 여야 합니다. 그런데 목록 20-19에서 썼던 것처럼 단순히 트레이트 이름을 붙이는 방식만으로는 해결되지 않습니다. 목록 20-21처럼 바꾸면 여전히 컴파일 오류가 납니다.

Filename: 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 {}", Animal::baby_name());
}
Listing 20-21: Animal 트레이트의 baby_name 을 호출하려 하지만, 러스트는 어떤 구현을 써야 할지 모른다

Animal::baby_nameself 를 받지 않기 때문에, 러스트는 Animal 을 구현하는 여러 타입 중 어느 타입의 구현 을 쓰려는 것인지 알 수 없습니다. 실제 컴파일러 오류는 다음과 같습니다.

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
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`.
error: could not compile `traits-example` (bin "traits-example") due to 1 previous error

이 모호성을 풀고, 우리가 원하는 것이 “Dog 에 구현된 Animalbaby_name” 이라는 사실을 알려 주려면, 목록 20-22처럼 완전 수식 문법을 써야 합니다.

Filename: 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 as Animal>::baby_name());
}
Listing 20-22: 완전 수식 문법으로 Dog 에 구현된 Animalbaby_name 을 호출하기

우리는 꺾쇠 괄호 안에 타입 주석을 제공함으로써, 이번 함수 호출에서는 Dog 타입을 Animal 로 취급해 그 위에 구현된 baby_name 을 호출하고 싶다고 러스트에게 말한 것입니다. 이제 이 코드는 우리가 원했던 결과를 출력합니다.

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
A baby dog is called a puppy

일반적인 완전 수식 문법은 다음과 같습니다.

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

메서드가 아닌 연관 함수라면 receiver_if_method 자리는 없고, 다른 인수들만 있게 됩니다. 사실 함수나 메서드 호출 어디에서든 이런 완전 수식 문법을 사용할 수 있습니다. 하지만 러스트가 다른 정보만으로도 어느 구현을 뜻하는지 알 수 있는 경우에는, 이 문법의 일부를 생략할 수 있습니다. 결국 이 더 장황한 문법이 필요한 경우는, 같은 이름을 가진 여러 구현이 있어서 러스트가 우리의 의도를 구분할 추가 정보가 필요한 상황뿐입니다.

슈퍼트레이트 사용하기

어떤 트레이트 정의는 다른 트레이트에 의존하도록 만들고 싶을 때가 있습니다. 즉 첫 번째 트레이트를 구현하려면, 두 번째 트레이트도 함께 구현해야 한다고 요구하고 싶은 상황입니다. 그렇게 하면 첫 번째 트레이트 정의 안에서 두 번째 트레이트의 연관 항목을 사용할 수 있기 때문입니다. 이때 앞서 요구되는 트레이트를 해당 트레이트의 슈퍼트레이트(supertrait) 라고 부릅니다.

예를 들어 어떤 값을 별표 테두리로 감싸 출력하는 outline_print 메서드를 가진 OutlinePrint 트레이트를 만들고 싶다고 해 봅시다. 표준 라이브러리의 Display 트레이트를 구현한 Point 구조체가 (x, y) 형태로 출력된다고 할 때, Point { x: 1, y: 3 } 에 대해 outline_print 를 호출하면 다음과 같이 출력되면 좋겠습니다.

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

outline_print 메서드 구현 안에서는 Display 의 기능을 사용하고 싶으므로, OutlinePrint 트레이트는 Display 를 구현한 타입에 대해서만 동작해야 한다고 명시해야 합니다. 이를 위해 트레이트 정의에서 OutlinePrint: Display 라고 쓰면 됩니다. 이것은 트레이트에 트레이트 바운드를 추가하는 것과 같은 개념입니다. 목록 20-23이 그 구현을 보여 줍니다.

Filename: 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));
    }
}

fn main() {}
Listing 20-23: Display 가 제공하는 기능을 요구하는 OutlinePrint 트레이트 구현하기

OutlinePrintDisplay 트레이트를 요구한다고 명시했기 때문에, Display 를 구현한 모든 타입에서 자동으로 제공되는 to_string 함수를 그대로 사용할 수 있습니다. 만약 트레이트 이름 뒤에 콜론과 Display 를 적지 않았다면, 현재 스코프에서 &Self 타입에 to_string 이 없다는 오류를 받았을 것입니다.

이제 Point 처럼 Display 를 구현하지 않은 타입에 OutlinePrint 를 구현하려 하면 어떤 일이 일어나는지 보겠습니다.

Filename: 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 가 요구되지만 구현되어 있지 않다는 오류가 납니다.

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:23
   |
20 | impl OutlinePrint for Point {}
   |                       ^^^^^ the trait `std::fmt::Display` is not implemented for `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();
   |       ^^^^^^^^^^^^^ the trait `std::fmt::Display` is not implemented for `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`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors

이 문제를 고치려면 PointDisplay 를 구현해, OutlinePrint 가 요구하는 제약을 만족시키면 됩니다. 다음과 같습니다.

Filename: src/main.rs
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 {}

use std::fmt;

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

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

그 뒤에는 PointOutlinePrint 를 구현하는 코드가 문제없이 컴파일되고, Point 인스턴스에 대해 outline_print 를 호출해 별표 테두리 안에 표시할 수 있게 됩니다.

외부 트레이트를 newtype 패턴으로 구현하기

10장의 “타입에 트레이트 구현하기” 절에서 우리는 orphan rule 을 언급했습니다. 이 규칙에 따르면, 어떤 트레이트와 타입 쌍에 대해 둘 중 하나 이상이 현재 크레이트 로컬일 때만 그 트레이트를 구현할 수 있습니다. 이 제한은 newtype 패턴을 사용하면 우회할 수 있습니다. newtype 패턴은 튜플 구조체 안에 한 필드만 두고, 우리가 트레이트를 구현하고 싶은 타입을 그 안에 감싸는 방식입니다. (튜플 구조체는 5장의 [“튜플 구조체로 서로 다른 타입 만들기”] tuple-structs 절에서 다뤘습니다.) 그러면 이 래퍼 타입은 우리 크레이트 안의 로컬 타입이 되므로, 여기에 대해 원하는 트레이트를 구현할 수 있게 됩니다. Newtype 이라는 용어는 Haskell 언어에서 왔습니다. 이 패턴에는 런타임 성능 비용이 없습니다. 래퍼 타입은 컴파일 시점에 제거되기 때문입니다.

예를 들어 DisplayVec<T> 에 직접 구현하고 싶다고 해 봅시다. 하지만 Display 트레이트와 Vec<T> 타입 둘 다 표준 라이브러리 안에 정의되어 있으므로, orphan rule 때문에 직접 구현할 수 없습니다. 대신 Vec<T> 인스턴스를 감싸는 Wrapper 구조체를 만들고, DisplayWrapper 에 구현하면 됩니다. 목록 20-24를 보세요.

Filename: 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}");
}
Listing 20-24: Vec<String> 을 감싸는 Wrapper 타입을 만들어 Display 구현하기

Display 구현 안에서는, Wrapper 가 튜플 구조체이고 Vec<T> 가 그 안의 첫 번째 항목이므로 self.0 을 사용해 내부 Vec<T> 값에 접근합니다. 이렇게 하면 Wrapper 에 대해 Display 기능을 사용할 수 있습니다.

하지만 이 기법의 단점은 Wrapper 가 완전히 새 타입 이라는 점입니다. 따라서 내부에 들어 있는 Vec<T> 의 메서드를 자동으로 함께 가지지는 않습니다. 만약 Wrapper 를 정말로 Vec<T> 처럼 다루고 싶다면, Vec<T> 의 메서드들을 Wrapper 에 직접 하나씩 구현해 self.0 에 위임해야 합니다. 또는 내부 타입의 모든 메서드를 그대로 노출하고 싶다면, 15장의 “스마트 포인터를 일반 참조처럼 다루기” 절에서 다룬 Deref 트레이트를 Wrapper 에 구현해도 됩니다. 반대로 Wrapper 타입의 동작을 일부로 제한하고 싶다면, 원하는 메서드만 골라 직접 구현해야 합니다.

이 newtype 패턴은 트레이트와 관련이 없는 경우에도 유용합니다. 이제는 시선을 바꿔, 러스트 타입 시스템과 상호작용하는 또 다른 고급 방법들을 살펴보겠습니다.