고급 트레이트
고급 트레이트 (Advanced Traits)
트레이트는 10장의 "Defining Shared Behavior with Traits" 절에서 처음 다뤘지만, 그땐 더 고급스러운 세부 내용은 건드리지 않았어요. 이제 Rust에 대한 이해가 더 깊어졌으니, 속살까지 파고들어 볼 때가 됐죠. 이번 장에서 다루는 기능들은 대부분 자주 쓰이진 않지만, 특정 상황에서 없으면 안 되는 것들이에요.
출처: The Rust Book
연관 타입으로 트레이트 정의하기 (Defining Traits with Associated Types)
_연관 타입(associated type)_은 타입 자리표시자(placeholder)를 트레이트에 연결해서, 트레이터 메서드 정의가 시그니처에서 이 자리표시자 타입을 쓸 수 있게 해요. 트레이트를 구현하는 쪽은 특정 구현에서 자리표시자 타입 대신 쓸 구체 타입을 지정하고요. 이렇게 하면 트레이트가 어떤 타입을 쓰는지 정확히 알지 못한 채로도, 트레이트가 구현되기 전까지는 그 타입을 몰라도 되는 트레이트를 정의할 수 있어요.
이번 장의 고급 기능 대부분은 "거의 안 쓴다"고 말했죠. 연관 타입은 그 중간쯤이에요. 책의 나머지에서 설명하는 기능보다는 덜 쓰이지만, 이번 장에서 다루는 다른 기능들보다는 더 흔하게 쓰여요.
연관 타입을 가진 트레이트의 예시로는 표준 라이브러리가 제공하는 Iterator 트레이트가 있어요. 연관 타입의 이름은 Item이고, Iterator 트레이트를 구현하는 타입이 반복해서 다루는 값의 타입을 나타내요. Iterator 트레이트의 정의는 Listing 20-13과 같아요.
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> {
// --snip--
if self.count < 5 {
self.count += 1;
Some(self.count)
} else {
None
}
}
}
이 문법은 제네릭의 문법과 비슷해 보이죠. 그럼 왜 Listing 20-14처럼 그냥 제네릭으로 Iterator 트레이트를 정의하지 않을까요?
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
차이는 이래요. Listing 20-14처럼 제네릭을 쓰면 각 구현에서 타입을 어노테이션해야 해요. Iterator<String> for Counter나 다른 어떤 타입으로든 구현할 수 있기 때문에, Counter에 대한 Iterator 구현이 여러 개 있을 수 있거든요. 다시 말해 트레이트에 제네릭 파라미터가 있으면, 그 트레이트는 한 타입에 여러 번 구현될 수 있고, 제네릭 타입 파라미터의 구체 타입을 매번 바꿀 수 있어요. Counter에서 next 메서드를 쓸 때, 우리가 쓰고 싶은 Iterator 구현이 어느 것인지 알려주는 타입 어노테이션을 제공해야 할 거예요.
연관 타입을 쓰면 타입을 어노테이션할 필요가 없어요. 트레이트를 한 타입에 여러 번 구현할 수 없으니까요. Listing 20-13의 연관 타입을 쓰는 정의에서는 Item의 타입을 한 번만 정할 수 있어요. impl Iterator for Counter는 하나만 있을 수 있으니까요. Counter에서 next를 호출할 때마다 u32 값의 이터레이터를 원한다고 지정할 필요가 없어요.
연관 타입은 또 트레이트의 계약의 일부가 돼요. 트레이트의 구현자는 연관 타입 자리표시자를 대신할 타입을 반드시 제공해야 하죠. 연관 타입은 보통 그 타입이 어떻게 쓰일지 설명하는 이름을 갖고, API 문서에 연관 타입을 문서화하는 건 좋은 관행이에요.
기본 제네릭 파라미터와 연산자 오버로딩 (Using Default Generic Parameters and Operator Overloading)
제네릭 타입 파라미터를 쓸 때는 그 제네릭 타입에 기본 구체 타입을 지정할 수 있어요. 이렇게 하면 기본 타입으로 충분한 경우, 트레이트 구현자가 구체 타입을 따로 지정할 필요가 없어져요. 기본 타입은 <PlaceholderType=ConcreteType> 문법으로 제네릭 타입을 선언하면서 지정해요.
이 기법이 유용한 상황의 대표적인 예가 _연산자 오버로딩(operator overloading)_인데, 특정 상황에서 연산자(예: +)의 동작을 사용자화하는 거예요.
Rust는 직접 연산자를 만들거나 임의의 연산자를 오버로딩하는 걸 허용하지 않아요. 하지만 std::ops에 나열된 연산과 그에 대응하는 트레이트는, 그 연산자와 연관된 트레이트를 구현해서 오버로딩할 수 있어요. 예를 들어 Listing 20-15에서 + 연산자를 오버로딩해 두 Point 인스턴스를 더해요. Point 구조체에 Add 트레이트를 구현해서 하죠.
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 값을 더하고, 두 Point 인스턴스의 y 값을 더해 새 Point를 만들어요. Add 트레이트에는 add 메서드가 반환하는 타입을 결정하는 Output이라는 연관 타입이 있죠.
이 코드의 기본 제네릭 타입은 Add 트레이트 내부에 있어요. 그 정의는 이렇습니다.
#![allow(unused)]
fn main() {
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 타입은 기본적으로 Self가 돼요. 즉 우리가 Add를 구현하고 있는 타입이 바로 그 Self이죠.
Point에 Add를 구현할 때는 두 Point 인스턴스를 더하고 싶었으므로 Rhs의 기본값을 썼어요. 이번에는 Rhs 타입을 기본값 대신 사용자화하고 싶은, Add 트레이트 구현 예시를 볼게요.
서로 다른 단위의 값을 담은 Millimeters와 Meters라는 두 구조체가 있어요. 기존 타입을 다른 구조체로 얇게 감싸는 이 기법을 _newtype 패턴_이라 부르는데, 자세한 건 아래의 "Implementing External Traits with the Newtype Pattern" 절에서 다룰게요. 밀리미터 값과 미터 값을 더하고 싶으면서, Add의 구현이 변환을 올바르게 해주길 원해요. Listing 20-16처럼 Millimeters에 대해 Meters를 Rhs로 하는 Add를 구현할 수 있어요.
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의 기본값 대신 Rhs 타입 파라미터의 값을 설정하기 위해 impl Add<Meters>를 지정해요.
기본 타입 파라미터는 주로 두 가지 방식으로 써요.
- 기존 코드를 깨지 않고 타입을 확장할 때
- 대부분의 사용자가 필요로 하지 않는 특정 경우에 사용자화를 허용할 때
표준 라이브러리의 Add 트레이트는 두 번째 용도의 예시예요. 보통은 같은 종류의 타입 두 개를 더하지만, Add 트레이트는 그 이상을 사용자화할 능력을 제공하죠. Add 트레이트 정의에서 기본 타입 파라미터를 쓴다는 건 대부분의 경우 추가 파라미터를 지정하지 않아도 된다는 뜻이에요. 다시 말해 구현 보일러플레이트가 조금 줄어들어, 트레이트를 쓰기 더 쉬워져요.
첫 번째 용도는 두 번째와 비슷하지만 반대예요. 기존 트레이트에 타입 파라미터를 추가하고 싶다면, 기본값을 줘서 기존 구현 코드를 깨지 않고도 트레이트의 기능을 확장할 수 있어요.
똑같은 이름의 메서드들 구분하기 (Disambiguating Between Identically Named Methods)
Rust는 트레이트가 다른 트레이트의 메서드와 같은 이름의 메서드를 갖는 걸 막지 않고, 한 타입에 두 트레이트를 모두 구현하는 것도 막지 않아요. 트레이트의 메서드와 같은 이름의 메서드를 타입에 직접 구현하는 것도 가능하죠.
같은 이름의 메서드를 호출할 때는 어느 것을 쓰고 싶은지 Rust에 알려줘야 해요. Listing 20-17의 코드를 보면 Pilot과 Wizard라는 두 트레이트를 정의하고, 둘 다 fly라는 메서드를 가져요. 그리고 이미 fly 메서드가 구현된 Human 타입에 두 트레이트를 모두 구현해요. fly 메서드마다 하는 일이 다르죠.
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() {}
Human 인스턴스에서 fly를 호출하면 컴파일러는 기본적으로 타입에 직접 구현된 메서드를 호출해요. Listing 20-18이 그걸 보여주죠.
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();
}
이 코드를 실행하면 *waving arms furiously*가 출력돼서, Rust가 Human에 직접 구현된 fly 메서드를 호출했다는 걸 보여줘요.
Pilot 트레이트나 Wizard 트레이트의 fly 메서드를 호출하려면, 어느 fly 메서드를 뜻하는지 지정하는 더 명시적인 문법이 필요해요. Listing 20-19가 이 문법을 보여줘요.
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();
}
메서드 이름 앞에 트레이트 이름을 지정하면 어느 fly 구현을 호출할지 Rust에 명확해져요. Listing 20-19에서 쓴 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 파라미터를 받으므로, 하나의 트레이트를 구현하는 두 _타입_이 있다 해도 Rust는 self의 타입을 보고 어떤 트레이트 구현을 쓸지 알아낼 수 있어요.
하지만 메서드가 아닌 연관 함수(associated function)는 self 파라미터가 없어요. 같은 함수 이름으로 메서드가 아닌 함수를 정의한 타입이나 트레이트가 여러 개 있으면, 완전 한정 문법(fully qualified syntax)을 쓰지 않는 한 Rust가 어느 타입을 뜻하는지 항상 알지는 못해요. 예를 들어 Listing 20-20에서는 모든 갓 태어난 강아지의 이름을 Spot으로 짓고 싶어 하는 동물 보호소를 위한 트레이트를 만들어요. 메서드가 아닌 연관 함수 baby_name을 가진 Animal 트레이트를 만들고, Dog 구조체에 Animal 트레이트를 구현하는데, Dog에는 메서드가 아닌 연관 함수 baby_name도 직접 제공해요.
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라고 부르는 건, Animal 트레이트와 연관된 baby_name 함수에서 Dog에 대한 Animal 트레이트 구현으로 표현돼요.
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가 출력되길 원해요. Listing 20-19에서 쓴 트레이트 이름을 지정하는 기법은 여기서 도움이 안 돼요. main을 Listing 20-21의 코드로 바꾸면 컴파일 오류가 나요.
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());
}
Animal::baby_name은 self 파라미터가 없고, Animal 트레이트를 구현하는 다른 타입이 있을 수 있으므로, Rust는 우리가 원하는 Animal::baby_name 구현이 어느 건지 알아낼 수 없어요. 그러면 이런 컴파일러 오류가 나요.
$ 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
구분을 위해, 어떤 다른 타입에 대한 Animal 구현이 아니라 Dog에 대한 Animal 구현을 쓰고 싶다고 Rust에 알리려면 완전 한정 문법을 써야 해요. Listing 20-22가 완전 한정 문법 사용법을 보여줘요.
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());
}
꺾쇠 괄호 안에 타입 어노테이션을 제공해, 이 함수 호출에서 Dog 타입을 Animal로 취급하고 싶다고 말함으로써 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는 없어요. 다른 인자들의 목록만 있죠. 함수나 메서드를 호출하는 모든 곳에서 완전 한정 문법을 쓸 수 있어요. 하지만 프로그램의 다른 정보로 Rust가 알아낼 수 있는 부분은 생략해도 돼요. 같은 이름을 쓰는 구현이 여러 개 있고, Rust가 어느 구현을 호출할지 식별하는 데 도움이 필요한 경우에만 이 더 장황한 문법을 쓰면 돼요.
슈퍼트레이트 사용하기 (Using Supertraits)
때로는 다른 트레이트에 의존하는 트레이트 정의를 쓰고 싶을 때가 있어요. 어떤 타입이 첫 번째 트레이트를 구현하려면 그 타입이 두 번째 트레이트도 구현하도록 요구하고 싶을 때죠. 이렇게 하는 이유는 트레이트 정의가 두 번째 트레이트의 연관 항목을 활용할 수 있게 하기 위해서예요. 트레이트 정의가 의존하는 트레이트를 그 트레이트의 _슈퍼트레이트(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라고 지정하면 돼요. 이 기법은 트레이트에 트레이트 바운드를 추가하는 것과 비슷해요. Listing 20-23은 OutlinePrint 트레이트의 구현을 보여줘요.
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() {}
OutlinePrint가 Display 트레이트를 요구하도록 지정했기 때문에, Display를 구현하는 어떤 타입에서든 자동으로 구현되는 to_string 함수를 쓸 수 있어요. 콜론을 추가해 트레이트 이름 뒤에 Display 트레이트를 지정하지 않고 to_string을 쓰려 하면, 현재 스코프에서 &Self 타입에 to_string이라는 메서드가 없다는 오류가 나요.
Display를 구현하지 않는 타입, 예를 들어 Point 구조체에 OutlinePrint를 구현하려고 하면 어떻게 되는지 볼게요.
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
이를 고치려면 Point에 Display를 구현해서 OutlinePrint가 요구하는 제약을 충족시키면 돼요.
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();
}
그러면 Point에 OutlinePrint 트레이트를 구현하는 게 성공적으로 컴파일되고, Point 인스턴스에서 outline_print을 호출해 별표 테두리 안에 표시할 수 있어요.
newtype 패턴으로 외부 트레이트 구현하기 (Implementing External Traits with the Newtype Pattern)
10장의 "Implementing a Trait on a Type" 절에서 고아 규칙(orphan rule)을 언급했어요. 트레이트나 타입, 또는 둘 다 우리 크레이트에 로컬인 경우에만 그 타입에 트레이트를 구현할 수 있다는 규칙이죠. 이 제한을 우회할 수 있는 방법이 newtype 패턴인데, 튜플 구조체에 새 타입을 만드는 걸 포함해요. (튜플 구조체는 5장의 "Creating Different Types with Tuple Structs" 절에서 다뤘어요.) 튜플 구조체는 필드가 하나이고, 트레이트를 구현하고 싶은 타입을 얇게 감싸는 래퍼가 돼요. 그러면 래퍼 타입은 우리 크레이트에 로컬이고, 래퍼에 트레이트를 구현할 수 있어요. _Newtype_은 Haskell 프로그래밍 언어에서 유래한 용어예요. 이 패턴을 쓴다고 해서 런타임 성능 패널티는 없고, 래퍼 타입은 컴파일 시점에 제거(elided)돼요.
예를 들어 Vec<T>에 Display를 구현하고 싶다고 해볼게요. Display 트레이트와 Vec<T> 타입이 모두 우리 크레이트 밖에 정의되어 있으므로, 고아 규칙이 직접 구현을 막아요. Vec<T> 인스턴스를 담는 Wrapper 구조체를 만든 다음, Wrapper에 Display를 구현하고 Vec<T> 값을 쓸 수 있어요. Listing 20-24가 보여주죠.
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}");
}
Display 구현은 self.0을 사용해 내부의 Vec<T>에 접근해요. Wrapper가 튜플 구조체이고 Vec<T>가 튜플의 인덱스 0 항목이기 때문이죠. 그러면 Wrapper에서 Display 트레이트의 기능을 쓸 수 있어요.
이 기법의 단점은 Wrapper가 새 타입이므로 담고 있는 값의 메서드가 없다는 점이에요. Wrapper를 정확히 Vec<T>처럼 취급할 수 있게 하려면, Vec<T>의 모든 메서드를 Wrapper에 직접 구현해 self.0에 위임(delegate)하도록 해야 해요. 새 타입이 내부 타입이 가진 모든 메서드를 갖길 원한다면, Wrapper에 Deref 트레이트를 구현해 내부 타입을 반환하게 하는 게 해결책이에요(15장의 "Treating Smart Pointers Like Regular References" 절에서 Deref 트레이트 구현을 다뤘죠). 만약 Wrapper 타입이 내부 타입의 모든 메서드를 갖는 걸 원치 않는다면 — 예를 들어 Wrapper 타입의 동작을 제한하고 싶다면 — 원하는 메서드만 직접 구현하면 돼요.
이 newtype 패턴은 트레이트가 관여하지 않는 경우에도 유용해요. 이제 초점을 바꿔 Rust의 타입 시스템과 상호작용하는 고급 방법 몇 가지를 볼게요.