콘텐츠로 이동

Rust 고급 트레이트

트레이트 페이지에서는 트레이트를 정의하고 구현하는 법을 배웠어요. 제네릭과 자주 쓰는 트레이트 페이지에서는 Display, From, Add 같은 트레이트를 써 봤고요. 평소 코드에는 그 정도면 충분해요. 그런데 표준 라이브러리나 유명한 크레이트의 코드를 읽다 보면, 처음엔 좀 이상해 보이는 트레이트 기능을 만나게 돼요. type Item, Add<Rhs = Self>, trait Pretty: Display, impl<T: Display> ... for T 같은 것들이요.

이 페이지에서는 그 기능들을 하나씩 설명해요. 마법 같은 건 하나도 없어요. 각 기능은 작지만 실제로 있는 문제를 하나씩 해결해 주고, 그 문제를 알고 나면 문법도 자연스럽게 이해돼요. 천천히 읽으세요. 한 절을 읽고, 코드를 실행해 보고, 나중에 다시 와도 괜찮아요.

연관 타입은 이미 만나 봤어요. Iterator에 있는 type Item이요. 연관 타입(associated type)은 트레이트 안에 있는 자리 표시용 타입이에요. 구현할 때마다 한 번씩 채워 넣어요.

작은 Container 트레이트를 하나 볼게요. 재생 목록은 String을 담고 점수 목록은 u32를 담으니까, 각자 자기 Item을 골라요.

use std::fmt::Debug;
trait Container {
type Item;
fn get(&self, index: usize) -> Option<&Self::Item>;
fn size(&self) -> usize;
fn first(&self) -> Option<&Self::Item> {
self.get(0)
}
}
struct Playlist {
songs: Vec<String>,
}
struct Scores {
values: Vec<u32>,
}
impl Container for Playlist {
type Item = String;
fn get(&self, index: usize) -> Option<&String> {
self.songs.get(index)
}
fn size(&self) -> usize {
self.songs.len()
}
}
impl Container for Scores {
type Item = u32;
fn get(&self, index: usize) -> Option<&u32> {
self.values.get(index)
}
fn size(&self) -> usize {
self.values.len()
}
}
// 아이템을 {:?}로 출력할 수 있는 Container라면 무엇이든 받아요
fn describe<C: Container>(c: &C)
where
C::Item: Debug,
{
println!("아이템 {}개, 첫 번째는 {:?}", c.size(), c.first());
}
fn main() {
let playlist = Playlist { songs: vec![String::from("Intro"), String::from("Outro")] };
let scores = Scores { values: vec![90, 72, 85] };
describe(&playlist);
describe(&scores);
}

실행 결과:

아이템 2개, 첫 번째는 Some("Intro")
아이템 3개, 첫 번째는 Some(90)
▶ 직접 실행해 보기

트레이트 안에서 Self::Item은 “이 구현이 고른 타입”을 뜻해요. describe의 C::Item도 타입 C에 대해 같은 뜻이에요. (where 줄은 바운드를 좀 더 깔끔하게 적는 자리일 뿐이에요. 이 페이지 끝부분에서 더 설명할게요.)

그냥 제네릭 매개변수를 쓰면 안 될까요? trait Container<T>라고 쓸 수도 있어요. 하지만 그러면 한 타입이 이 트레이트를 여러 번 구현할 수 있게 돼요. Container<String>과 Container<u32>를 둘 다요. 그리고 호출하는 쪽에서는 매번 어느 쪽을 말하는지 밝혀야 해요. 재생 목록에는 말이 안 되는 얘기예요. 재생 목록은 노래를 담는 거니까요. 그게 전부예요.

Iterator가 type Item을 쓰는 이유가 바로 이거예요. Vec<i32>의 반복자는 i32만 내놓고 다른 건 내놓지 않아요. 그래서 for x in v.iter()에 타입을 적을 필요가 없어요. 간단한 기준을 정리하면 이래요.

  • 타입마다 자연스러운 답이 하나 → 연관 타입(Iterator::Item, Add::Output).
  • 타입마다 맞는 답이 여러 개 → 제네릭 매개변수(From<T>: String은 &str, char, Box<str>, … 등 여러 가지로 만들 수 있어요).

자주 쓰는 트레이트 페이지에서는 Add를 구현해서 Point에 +를 쓸 수 있게 만들었어요. 표준 라이브러리에서 Add는 실제로 이렇게 정의되어 있어요.

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

여기서 두 가지 기능이 만나요. Output은 연관 타입이에요(+가 만들어 내는 결과). 그리고 Rhs = Self는 기본 타입 매개변수(default type parameter)예요. “따로 말하지 않으면 오른쪽 피연산자는 왼쪽과 같은 타입”이라는 뜻이에요. impl Add for Point라고 쓰면 기본값을 쓰게 돼요. 다른 타입을 더하고 싶다면 impl Add<OtherType> for Point라고 쓰면 돼요.

use std::ops::Add;
#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Centimeters(f64);
// 기본값 Rhs = Self를 써요. 그러니까 Meters + Meters예요
impl Add for Meters {
type Output = Meters;
fn add(self, other: Meters) -> Meters {
Meters(self.0 + other.0)
}
}
// 기본값을 바꿔요: Meters + Centimeters
impl Add<Centimeters> for Meters {
type Output = Meters;
fn add(self, other: Centimeters) -> Meters {
Meters(self.0 + other.0 / 100.0)
}
}
fn main() {
let a = Meters(2.0);
let b = Meters(3.5);
let c = Centimeters(50.0);
println!("{:?}", a + b);
println!("{:?}", a + c);
}

실행 결과:

Meters(5.5)
Meters(2.5)
▶ 직접 실행해 보기

Add가 두 도구를 모두 쓴다는 점을 눈여겨보세요. 오른쪽 피연산자에는 제네릭 매개변수를 써요(맞는 답이 여러 개예요. Meters도 Centimeters도 더할 수 있으니까요). 결과에는 연관 타입을 써요(조합마다 답이 하나예요). 기본 타입 매개변수는 주로 트레이트를 설계할 때 유용해요. 흔한 경우는 짧게 쓰게 해 주면서, 드문 경우도 막지 않거든요.

슈퍼트레이트: “이것도 구현해야 해요”

섹션 제목: “슈퍼트레이트: “이것도 구현해야 해요””

어떤 트레이트는 그 타입이 이미 다른 일을 할 수 있을 때만 의미가 있어요. 슈퍼트레이트(supertrait)는 “내 트레이트를 구현하려면 저 트레이트도 구현해야 해요”라고 말하는 방법이에요. 바운드처럼 콜론으로 적어요.

use std::fmt;
// Pretty인 것은 반드시 Display이기도 해야 해요
trait Pretty: fmt::Display {
fn pretty(&self) -> String {
format!("*** {} ***", self)
}
}
struct Temperature {
celsius: f64,
}
impl fmt::Display for Temperature {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:.1}°C", self.celsius)
}
}
impl Pretty for Temperature {}
fn main() {
let t = Temperature { celsius: 21.456 };
println!("{}", t);
println!("{}", t.pretty());
}

실행 결과:

21.5°C
*** 21.5°C ***
▶ 직접 실행해 보기

기본 메서드 pretty가 self에 {}를 쓸 수 있는 건, 슈퍼트레이트가 Display가 있다고 약속해 주기 때문이에요. : fmt::Display가 없다면 Rust는 그 타입을 출력할 수 있는지 알 수 없으니 format!을 거부할 거예요. impl Pretty for Temperature {}는 비어 있어요. “네, 기본 메서드를 쓸게요”라고 말할 뿐이에요.

표준 라이브러리에 있는 실제 예로는 Copy: Clone(모든 Copy 타입은 Clone이기도 해야 해요)과 Eq: PartialEq가 있어요.

블랭킷 구현: 구현 하나로 여러 타입을

섹션 제목: “블랭킷 구현: 구현 하나로 여러 타입을”

블랭킷 구현(blanket implementation)은 조건을 만족하는 모든 타입에 트레이트를 한 번에 구현해요. 문법은 impl<T: SomeBound> MyTrait for T예요.

use std::fmt::Display;
trait Shout {
fn shout(&self) -> String;
}
// Display를 구현한 모든 타입이 Shout를 공짜로 얻어요
impl<T: Display> Shout for T {
fn shout(&self) -> String {
format!("{}!", self.to_string().to_uppercase())
}
}
fn main() {
println!("{}", "hello".shout());
println!("{}", 42.shout());
println!("{}", 'a'.shout());
println!("{}", String::from("rust is fun").shout());
}

실행 결과:

HELLO!
42!
A!
RUST IS FUN!
▶ 직접 실행해 보기

이 예제의 문자열은 영어 그대로 두었어요. 한글에는 대문자가 없어서 to_uppercase()를 써도 아무것도 바뀌지 않거든요.

impl Shout for i32나 impl Shout for char는 한 번도 쓰지 않았어요. 블랭킷 구현이 전부 처리해 줬어요. 아직 존재하지도 않는 Display 타입까지 포함해서요.

사실 블랭킷 구현은 이미 모르는 사이에 여러 번 써 왔어요. 표준 라이브러리에는 (대략) impl<T: Display> ToString for T가 있어요. 그래서 Display가 있는 모든 타입에 .to_string() 메서드가 있는 거예요. From을 구현하면 Into가 공짜로 생기는 것도 같은 방법이에요.

한계가 하나 있어요. 블랭킷 구현이 있으면, 그 구현이 이미 덮고 있는 타입에 더 구체적인 구현을 따로 추가할 수 없어요(예를 들어 특별한 impl Shout for i32). Rust 입장에서는 답이 두 개가 되니까, “conflicting implementations”(구현이 충돌한다) 에러를 내며 거부해요.

고아 규칙과 뉴타입으로 우회하기

섹션 제목: “고아 규칙과 뉴타입으로 우회하기”

누구나 한 번쯤 놀라는 규칙이 있어요. Vec<String>에 보기 좋은 Display를 붙여 보세요.

use std::fmt;
impl fmt::Display for Vec<String> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.join(", "))
}
}
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
--> src/main.rs:3:1
|
3 | impl fmt::Display for Vec<String> {
| ^^^^^^^^^^^^^^^^^^^^^^-----------
| |
| `Vec` is not defined in the current crate
|
= note: define and implement a trait or new type instead

“크레이트 밖에서 정의된 타입에는 현재 크레이트에서 정의한 트레이트만 구현할 수 있다”는 에러예요. 이게 고아 규칙(orphan rule)이에요. 트레이트를 어떤 타입에 구현하려면, 트레이트나 타입 중 하나는 내 크레이트에 정의되어 있어야 해요. Display도 표준 라이브러리 것이고 Vec도 표준 라이브러리 것이라서, 둘 다 내 것이 아니에요.

이 규칙은 왜 있을까요? 내가 의존하는 크레이트 두 개가 각자 impl Display for Vec<String>을 만들었다고 상상해 보세요. Rust는 어느 쪽을 써야 할까요? 좋은 답이 없으니, Rust는 아예 그런 상황이 생길 수 없게 만들어요.

우회 방법은 뉴타입 패턴(newtype pattern)이에요. 바깥 타입을 내가 만든 작은 구조체로 감싸는 거예요. 이제 그 타입은 내 것이니까 무엇이든 구현할 수 있어요.

use std::fmt;
// 바깥 타입을 감싼 내 래퍼
struct Shopping(Vec<String>);
impl fmt::Display for Shopping {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let list = Shopping(vec![String::from("우유"), String::from("달걀"), String::from("빵")]);
println!("살 것: {}", list);
println!("모두 {}개예요", list.0.len());
}

실행 결과:

살 것: [우유, 달걀, 빵]
모두 3개예요
▶ 직접 실행해 보기

self.0으로 안에 있는 Vec에 닿을 수 있어요. 뉴타입은 실행 비용이 전혀 없어요. 메모리에서 Shopping은 Vec<String>과 똑같거든요. 단점은 Vec의 메서드가 Shopping에 바로 붙어 있지 않다는 거예요. 그래서 list.0.len()처럼 쓰거나, 필요한 메서드를 직접 추가해야 해요. 뉴타입은 고아 규칙 말고도 쓸모가 훨씬 많아요. 디자인 패턴 페이지를 참고하세요.

완전 정규화 문법: 이름이 겹칠 때

섹션 제목: “완전 정규화 문법: 이름이 겹칠 때”

트레이트 두 개에 같은 이름의 메서드가 있을 수 있고, 타입 자체에도 같은 이름의 메서드가 있을 수 있어요. 그럼 robot.greet()는 어느 것을 호출할까요?

trait English {
fn greet(&self) -> String;
fn language() -> String;
}
trait Spanish {
fn greet(&self) -> String;
fn language() -> String;
}
struct Robot;
impl Robot {
fn greet(&self) -> String {
String::from("삐빅 삐빅")
}
}
impl English for Robot {
fn greet(&self) -> String { String::from("Hello!") }
fn language() -> String { String::from("영어") }
}
impl Spanish for Robot {
fn greet(&self) -> String { String::from("¡Hola!") }
fn language() -> String { String::from("스페인어") }
}
fn main() {
let r = Robot;
println!("{}", r.greet()); // 타입 자신의 메서드가 이겨요
println!("{}", English::greet(&r)); // 트레이트를 이름으로 골라요
println!("{}", Spanish::greet(&r));
// 여기에는 `self`가 없어서, Rust에게 타입과 트레이트를 둘 다 알려 줘야 해요:
println!("{}", <Robot as English>::language());
println!("{}", <Robot as Spanish>::language());
}

실행 결과:

삐빅 삐빅
Hello!
¡Hola!
영어
스페인어
▶ 직접 실행해 보기

짧은 것부터 완전히 명시적인 것까지, 세 단계가 있어요.

  • r.greet(): 타입 자신의(고유한) 메서드가 먼저 선택돼요.
  • English::greet(&r): “English 버전”이라는 뜻이에요. 타입은 Rust가 &r을 보고 알아내요.
  • <Robot as English>::language(): 완전 정규화 문법(fully qualified syntax)이에요. self가 없는 함수에는 이게 필요해요. Rust가 살펴볼 값이 없으니까요. English::language()만 쓰면 error[E0790]: cannot call associated function on trait without specifying the corresponding impl type 에러가 나요. “어느 타입의 구현인지 밝히지 않고는 트레이트의 연관 함수를 호출할 수 없다”는 뜻이에요.

이 문법이 자주 필요하지는 않아요. 필요할 때는 대개 컴파일러 에러가 정확한 해결 방법을 알려 줘요.

인자와 반환값의 impl Trait, 그리고 where

섹션 제목: “인자와 반환값의 impl Trait, 그리고 where”

impl Trait는 두 군데에 쓸 수 있는데, 자리마다 뜻이 조금 달라요.

  • 인자 자리, fn f(x: impl Trait): 호출하는 쪽이 타입을 골라요. 제네릭 fn f<T: Trait>(x: T)를 짧게 쓰는 방법이에요.
  • 반환 자리, fn f() -> impl Trait: 함수가 구체적인 타입 하나를 골라서 숨겨요. 호출하는 쪽은 “Trait를 구현한 무언가”라는 것만 알아요.
// 호출하는 쪽이 골라요: u32를 내놓는 반복자라면 무엇이든 괜찮아요
fn print_all(items: impl Iterator<Item = u32>) {
let all: Vec<u32> = items.collect();
println!("{:?}", all);
}
// 함수가 골라요: 실제 타입은 직접 적을 일 없는 긴 Filter<...>예요
fn evens_up_to(limit: u32) -> impl Iterator<Item = u32> {
(0..=limit).filter(|n| n % 2 == 0)
}
fn main() {
print_all(vec![3, 1, 4].into_iter());
print_all(evens_up_to(10));
}

실행 결과:

[3, 1, 4]
[0, 2, 4, 6, 8, 10]
▶ 직접 실행해 보기

반환 자리의 impl Trait는 클로저나 반복자와 함께 쓸 때 특히 편해요. evens_up_to 결과의 실제 타입에는 클로저의 타입이 들어 있는데, 클로저 타입에는 우리가 적을 수 있는 이름이 없거든요. impl Iterator를 쓰면 그걸 적지 않고 넘어갈 수 있어요.

함수에 제네릭 매개변수가 여러 개 있고 각각 바운드도 여러 개라면 꺾쇠괄호 안이 복잡해져요. where 절을 쓰면 바운드를 시그니처 뒤로 옮길 수 있어서, 함수의 생김새가 한눈에 들어와요.

use std::fmt::{Debug, Display};
// 복잡해요: fn report<T: Display, U: Debug + PartialOrd>(label: T, a: U, b: U) -> String
// 깔끔해요:
fn report<T, U>(label: T, a: U, b: U) -> String
where
T: Display,
U: Debug + PartialOrd,
{
let bigger = if a > b { a } else { b };
format!("{}: 더 큰 값은 {:?}", label, bigger)
}
fn main() {
println!("{}", report("점수", 7, 12));
println!("{}", report(String::from("이름"), "ann", "bob"));
}

실행 결과:

점수: 더 큰 값은 12
이름: 더 큰 값은 "bob"
▶ 직접 실행해 보기

컴파일러에게 두 형태는 완전히 같은 뜻이에요. 이 페이지 첫 예제의 C::Item: Debug처럼, 연관 타입에 바운드를 걸 때도 where가 자연스러운 자리예요.

트레이트는 구현했는데 슈퍼트레이트를 빼먹기. 슈퍼트레이트 예제에서 impl fmt::Display for Temperature 블록을 지우면 이런 에러가 나요.

error[E0277]: `Temperature` doesn't implement `std::fmt::Display`
--> src/main.rs:13:17
|
13 | impl Pretty for Temperature {}
| ^^^^^^^^^^^ unsatisfied trait bound
|
note: required by a bound in `Pretty`
--> src/main.rs:3:15
|
3 | trait Pretty: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `Pretty`

“Temperature가 Display를 구현하지 않았다”는 뜻이에요. 해결 방법은 슈퍼트레이트(여기서는 Display)도 구현하는 거예요. note 부분이 그걸 요구하는 : fmt::Display를 정확히 가리키고 있어요.

-> impl Trait에서 서로 다른 두 타입을 반환하기. impl Trait는 한 가지 타입을 숨겨요. “어떨 땐 이거, 어떨 땐 저거”가 될 수는 없어요.

fn numbers(evens: bool) -> impl Iterator<Item = u32> {
if evens {
(0..10).filter(|n| n % 2 == 0)
} else {
vec![1, 3, 5].into_iter()
}
}
error[E0308]: `if` and `else` have incompatible types
--> src/main.rs:5:9
|
2 | / if evens {
3 | | (0..10).filter(|n| n % 2 == 0)
| | ------------------------------
| | | |
| | | the expected closure
| | expected because of this
4 | | } else {
5 | | vec![1, 3, 5].into_iter()
| | ^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Filter<Range<{integer}>, {closure@...}>`, found `IntoIter<{integer}>`
6 | | }
| |_____- `if` and `else` have incompatible types
|
= note: expected struct `Filter<std::ops::Range<{integer}>, {closure@src/main.rs:3:24: 3:27}>`
found struct `std::vec::IntoIter<{integer}>`
help: you could change the return type to be a boxed trait object
|
1 - fn numbers(evens: bool) -> impl Iterator<Item = u32> {
1 + fn numbers(evens: bool) -> Box<dyn Iterator<Item = u32>> {
|
help: if you change the return type to expect trait objects, box the returned expressions
|
3 ~ Box::new((0..10).filter(|n| n % 2 == 0))
4 | } else {
5 ~ Box::new(vec![1, 3, 5].into_iter())
|

“if와 else의 타입이 서로 맞지 않는다”는 에러예요. 타입이 정말로 실행 중에 바뀐다면, 컴파일러가 제안하는 것처럼 대신 Box<dyn Trait>를 반환하세요. 트레이트 객체 페이지를 참고하세요.

  • 연관 타입(type Item)은 구현마다 답을 하나씩 줘요. 한 타입이 트레이트를 여러 방식으로 구현할 수 있어야 한다면 제네릭 매개변수를 쓰세요.
  • Add<Rhs = Self> 같은 기본 타입 매개변수는 흔한 경우를 짧게 쓰게 해 줘요. impl Add<Other> for T로 바꿀 수 있어요.
  • 슈퍼트레이트(trait Pretty: Display)는 다른 트레이트를 요구하고, 기본 메서드에서 그 트레이트를 쓸 수 있게 해 줘요.
  • 블랭킷 구현(impl<T: Display> MyTrait for T)은 조건에 맞는 모든 타입을 한 번에 덮어요. .to_string()도 이렇게 동작해요.
  • 고아 규칙 때문에 바깥 트레이트를 바깥 타입에 구현할 수는 없어요. 타입을 뉴타입으로 감싸서 우회하세요.
  • 이름이 겹치면 완전 정규화 문법 <Type as Trait>::function()을 써요. 반환 자리의 impl Trait는 구체적인 타입 하나를 숨기고, where는 긴 바운드를 읽기 쉽게 해 줘요.

이해도 점검

1. Iterator는 왜 Iterator<T> 대신 type Item을 쓸까요?

2. trait Add<Rhs = Self>에서 Rhs = Self는 무슨 뜻일까요?

3. impl Display for Vec<String>을 쓰고 싶은데 Rust가 거부해요. 흔히 쓰는 해결 방법은 무엇일까요?

4. self 매개변수가 없는 함수의 English 버전을 호출하는 줄은 무엇일까요?

5. fn evens() -> impl Iterator<Item = u32>에서 구체적인 반복자 타입은 누가 정할까요?

점수: 0 / 5