본문 바로가기

트레이트와 공통 동작

트레이트의 공통 동작과 기본 구현을 정의하고 trait bound·where·impl Trait로 허용할 타입의 능력을 제한합니다.

트레이트(trait)는 특정한 타입이 가지면서 다른 타입과 함께 쓸 수 있는 기능을 정의합니다. 트레이트로 공통 동작을 추상적으로 정의할 수 있고, 트레이트 바운드(trait bound)로 제네릭 타입이 특정한 동작을 하는 어떤 타입이든 될 수 있다고 지정할 수 있습니다. 트레이트는 자바의 인터페이스처럼 다른 언어에서 인터페이스(interface)라고 부르는 기능과 비슷하지만 몇 가지 다릅니다. 이 글에서는 트레이트를 정의하고 구현하는 방법, 기본 구현, 매개변수와 반환 타입에 트레이트를 쓰는 방법, 그리고 트레이트 바운드로 메서드를 조건부로 구현하는 방법을 살펴봅니다.

트레이트 정의하기

타입의 동작은 그 타입에 부를 수 있는 메서드로 이루어집니다. 서로 다른 타입에 같은 메서드를 부를 수 있다면 그 타입들은 같은 동작을 공유하는 것입니다. 트레이트는 메서드 시그니처를 묶어, 어떤 목적에 필요한 동작의 집합을 정의합니다.

예를 들어 여러 종류와 양의 텍스트를 담는 구조체가 여럿 있다고 합시다. 특정 장소에서 쓴 뉴스 기사를 담는 NewsArticle 구조체와, 최대 280자의 글과 함께 새 글인지 다시 올린 글인지 다른 글에 단 답글인지를 나타내는 메타데이터를 담는 SocialPost 구조체입니다. NewsArticle이나 SocialPost 인스턴스에 저장된 데이터의 요약을 보여 주는 aggregator라는 미디어 집계 라이브러리 크레이트를 만들려면, 각 타입에서 요약을 얻어야 하므로 인스턴스에 summarize 메서드를 불러 요약을 받습니다. 다음은 이 동작을 나타내는 공개 트레이트 Summary의 정의입니다.

src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String;
}

trait 키워드와 트레이트 이름 Summary로 트레이트를 선언했으며, 이 크레이트에 의존하는 다른 크레이트도 이 트레이트를 쓸 수 있도록 pub을 붙였습니다. 중괄호 안에는 이 트레이트를 구현하는 타입의 동작을 나타내는 메서드 시그니처를 적으며, 여기서는 fn summarize(&self) -> String입니다. 시그니처 뒤에는 중괄호 대신 세미콜론을 씁니다. 이 트레이트를 구현하는 타입마다 메서드의 본문을 자기 동작에 맞게 써야 하며, 컴파일러는 Summary를 구현한 타입에 이 시그니처의 summarize가 정의되어 있는지 확인합니다. 트레이트에는 메서드를 여러 개 둘 수 있으며, 한 줄에 시그니처 하나씩 세미콜론으로 끝냅니다.

특정 타입에 트레이트 구현하기

이제 Summary의 메서드 시그니처를 정했으니, 미디어 집계기의 타입에 구현합니다. 다음 코드는 NewsArticle에는 제목과 작성자, 장소로 요약을 만들고, SocialPost에는 글 전체가 280자로 짧으므로 사용자 이름과 글 전체로 요약을 만들도록 Summary를 구현합니다.

src/lib.rs
pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

타입에 트레이트를 구현하는 것은 보통의 메서드를 구현하는 것과 비슷합니다. 다른 점은 impl 뒤에 구현할 트레이트의 이름을 쓰고, for 키워드와 그 트레이트를 구현할 타입의 이름을 쓴다는 것입니다. impl 블록 안에는 트레이트에 정의한 메서드 시그니처를 적되, 세미콜론 대신 중괄호와 그 타입에 맞는 본문을 씁니다. 이제 라이브러리를 쓰는 사용자는 보통의 메서드처럼 NewsArticle과 SocialPost 인스턴스에 트레이트 메서드를 부를 수 있습니다. 다만 트레이트도 메서드와 함께 스코프로 가져와야 합니다.

src/main.rs
use aggregator::{SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new post: {}", post.summarize());
}
1 new post: horse_ebooks: of course, as you probably already know, people

aggregator 크레이트에 의존하는 다른 크레이트도 Summary 트레이트를 스코프로 가져와 자기 타입에 구현할 수 있습니다. 다만 트레이트를 구현할 때 지켜야 할 제약이 하나 있습니다. 트레이트나 타입 가운데 적어도 하나가 지금 크레이트에 정의되어 있어야 그 타입에 그 트레이트를 구현할 수 있습니다. 예를 들어 SocialPost는 aggregator 크레이트에 있으므로, aggregator에서 표준 라이브러리의 Display 트레이트를 SocialPost에 구현할 수 있고, Summary도 aggregator에 있으므로 Vec<T>에 Summary를 구현할 수도 있습니다.

하지만 바깥의 트레이트를 바깥의 타입에 구현할 수는 없습니다. 예를 들어 aggregator 안에서 Vec<T>에 Display를 구현할 수 없는데, Display와 Vec<T>가 모두 표준 라이브러리에 정의되어 있어 aggregator에 속하지 않기 때문입니다. 이 제약은 일관성(coherence)이라는 성질, 구체적으로는 부모 타입이 없다는 뜻에서 고아 규칙(orphan rule)이라고 부르는 규칙의 일부입니다. 이 규칙 덕분에 다른 사람의 코드가 우리 코드를 깨뜨릴 수 없고 그 반대도 마찬가지입니다. 이 규칙이 없다면 두 크레이트가 같은 타입에 같은 트레이트를 구현할 수 있고, 러스트는 어느 구현을 써야 할지 알 수 없을 것입니다. 바깥의 타입에 바깥의 트레이트를 구현하는 방법은 고급 트레이트에서 다룹니다.

기본 구현

트레이트의 메서드 일부나 전부에 기본 동작을 정해 두면, 모든 타입에 모든 메서드를 구현하라고 하지 않아도 되어 편할 때가 있습니다. 그러면 특정 타입에 트레이트를 구현할 때 메서드마다 기본 동작을 그대로 쓰거나 바꿀 수 있습니다. 다음 코드는 Summary의 summarize에 시그니처만 정의하는 대신 기본 문자열을 정합니다.

src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

NewsArticle 인스턴스에 이 기본 구현을 쓰려면 빈 impl 블록 impl Summary for NewsArticle {}을 적습니다. NewsArticle에 summarize를 직접 정의하지 않았지만, 기본 구현이 있고 NewsArticle이 Summary를 구현한다고 밝혔으므로 여전히 summarize를 부를 수 있습니다.

    let article = NewsArticle {
        headline: String::from("Penguins win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from(
            "The Pittsburgh Penguins once again are the best \
             hockey team in the NHL.",
        ),
    };

    println!("New article available! {}", article.summarize());
New article available! (Read more...)

기본 구현을 만들어도 SocialPost의 Summary 구현은 고칠 필요가 없습니다. 기본 구현을 덮어쓰는 문법이, 기본 구현이 없는 트레이트 메서드를 구현하는 문법과 같기 때문입니다.

기본 구현은 같은 트레이트의 다른 메서드를 부를 수 있으며, 그 메서드에 기본 구현이 없어도 됩니다. 이렇게 하면 트레이트가 쓸모 있는 기능을 많이 제공하면서도, 구현하는 쪽은 작은 부분만 정하면 됩니다. 예를 들어 구현이 꼭 필요한 summarize_author 메서드를 두고, summarize의 기본 구현이 summarize_author를 부르게 할 수 있습니다.

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

이 Summary를 쓰려면 타입에 트레이트를 구현할 때 summarize_author만 정의하면 됩니다. summarize에는 기본 구현이 있지만 summarize_author에는 없으므로, 구현하는 쪽이 반드시 채워야 하는 것은 기본 구현이 없는 메서드뿐입니다.

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

summarize_author를 정의하면 SocialPost 인스턴스에 summarize를 부를 수 있고, summarize의 기본 구현이 우리가 정의한 summarize_author를 부릅니다. 앞의 main을 그대로 실행하면 1 new post: (Read more from @horse_ebooks...)가 출력됩니다. 다만 같은 메서드를 덮어쓰는 구현에서 그 메서드의 기본 구현을 부를 수는 없습니다.

아래 그림은 기본 구현을 쓰는 타입과 덮어쓰는 타입이 섞인 다음 예제를 따라갑니다.

Rust 트레이트 구현과 coherence 결정 흐름

Post.summarize가 기본 Summary.summarize를 사용하고 그 본문이 Post.summarize_author를 호출한 뒤 요약을 반환하는 실제 호출 순서다. Alert 재정의는 별도 호출 비교로 유지하며 Post 다음에 Alert로 전환하는 화살표를 넣지 않는다.

Rust 트레이트가 필수 메서드와 기본 구현으로 공통 동작을 정의하고, 현재 크레이트가 트레이트나 대상 타입을 정의한 기본 예제에서는 구현할 수 있지만 외부 트레이트와 외부 타입의 조합은 coherence와 고아 규칙 때문에 거부되는 결정 흐름

TRAIT CONTRACT · IMPL · DEFAULT · COHERENCE

트레이트 구현의 동작·위치 규칙

트레이트는 공유 동작 계약을 정의하고, impl Trait for Type은 구현을 선언합니다. 컴파일러는 시그니처와 구현 위치도 검사합니다.

기본 동작과 재정의의 호출 관계Post와 Alert는 서로 다른 두 호출 사례다. Post.summarize는 기본 Summary.summarize를 사용하고 그 본문이 Post.summarize_author를 호출한다. Alert.summarize는 재정의한 본문을 직접 사용한다. 실선은 동작 선택과 호출이며 Post에서 Alert로 이어지는 실행 화살표는 없다.기본 사용호출재정의Post.summarize()재정의 없음Summary.summarize기본 본문Post.summarize_author구현한 필수 동작Alert.summarize()재정의 있음Alert.summarize재정의 본문
기본 동작과 재정의의 호출 관계Post와 Alert는 서로 다른 두 호출 사례다. Post.summarize는 기본 Summary.summarize를 사용하고 그 본문이 Post.summarize_author를 호출한다. Alert.summarize는 재정의한 본문을 직접 사용한다. 실선은 동작 선택과 호출이며 Post에서 Alert로 이어지는 실행 화살표는 없다. 모바일의 위 세 노드는 Post 사례이고 아래 두 노드는 Alert 사례다. 서로 연결하지 않는다.기본 사용호출재정의Post · 기본 동작 사용Alert · 재정의 사용Post.summarize()재정의 없음Summary.summarize기본 본문Post의 필수 구현summarize_authorAlert.summarize()재정의 있음Alert.summarize재정의 본문
  1. 트레이트가 필수·기본 동작을 선언한다

    세미콜론 선언은 구현자가 채웁니다. 본문은 기본 동작이며 그대로 쓰거나 같은 시그니처로 재정의합니다.

  2. 트레이트와 타입의 정의 주체를 확인한다

    아래 예제에서는 트레이트나 타입 중 하나를 현재 crate가 정의하면 구현할 수 있습니다. 둘 다 외부 정의면 충돌을 막기 위해 거부됩니다.

  3. 허용된 impl이 계약을 완성한다

    필수 동작이 남으면 빈 impl은 불완전합니다. 각 동작은 기본 구현이 있거나 직접 제공되어야 계약이 성립합니다.

required and default methods

기본 동작은 필수 동작을 부를 수 있다

pub trait Summary {
    fn summarize_author(
        &self,
    ) -> String;

    fn summarize(&self) -> String {
        format!(
            "(Read more from {}...)",
            self.summarize_author(),
        )
    }
}

impl Summary for Tweet {
    fn summarize_author(
        &self,
    ) -> String {
        format!("@{}", self.username)
    }
}

Tweet은 summarize_author를 구현해 기본 summarize를 씁니다. 재정의하면 기본 본문을 부를 수 없습니다.

현재 문서의 세 예제로 읽는 구현 가능성
트레이트 정의 위치대상 타입 정의 위치예결과와 이유
현재 crate외부 crateSummary + Vec<T>allowed 현재 crate 소유 트레이트.
외부 crate현재 crateDisplay + Tweetallowed 현재 crate 소유 타입.
외부 crate외부 crateDisplay + Vec<T>rejected 외부 조합의 충돌 가능성.
local trait

Summary + Vec<T>

외부 타입이어도 이 crate의 트레이트면 허용됩니다.

local type

Display + Tweet

외부 트레이트여도 이 crate의 타입이면 허용됩니다.

both external

Display + Vec<T>

트레이트와 타입이 모두 외부라 이 crate에서는 거부됩니다.

“트레이트나 타입 중 하나는 내 것”은 직접 예제 요약입니다. 제네릭·fundamental type까지 반영한 컴파일러 coherence 검사가 최종 허용 여부를 판정합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
Post 호출

재정의가 없는 Post.summarize를 부른다.

  • 선택: Summary 기본 본문
  • 구체 타입: Post
  • Alert의 재정의와 이어 실행하는 과정이 아니다.
필수 동작 호출

기본 본문이 Post.summarize_author를 부른다.

  • 호출: Post.summarize_author
  • 필수 구현: Post가 제공
  • 기본 본문이 필수 메서드 구현을 대신해 주지 않는다.
요약 반환

author 결과로 기본 본문의 요약을 만든다.

  • 결과: 기본 본문의 요약 String
  • Alert: 독립 재정의 비교
  • 현재 Post 호출의 결과만 반환하며, 다른 타입으로 상태가 바뀌지 않는다.

Rust 트레이트 구현과 coherence 결정 흐름

Post.summarize가 기본 Summary.summarize를 사용하고 그 본문이 Post.summarize_author를 호출한 뒤 요약을 반환하는 실제 호출 순서다. Alert 재정의는 별도 호출 비교로 유지하며 Post 다음에 Alert로 전환하는 화살표를 넣지 않는다.

Rust 트레이트가 필수 메서드와 기본 구현으로 공통 동작을 정의하고, 현재 크레이트가 트레이트나 대상 타입을 정의한 기본 예제에서는 구현할 수 있지만 외부 트레이트와 외부 타입의 조합은 coherence와 고아 규칙 때문에 거부되는 결정 흐름

TRAIT CONTRACT · IMPL · DEFAULT · COHERENCE

트레이트 구현의 동작·위치 규칙

트레이트는 공유 동작 계약을 정의하고, impl Trait for Type은 구현을 선언합니다. 컴파일러는 시그니처와 구현 위치도 검사합니다.

기본 동작과 재정의의 호출 관계Post와 Alert는 서로 다른 두 호출 사례다. Post.summarize는 기본 Summary.summarize를 사용하고 그 본문이 Post.summarize_author를 호출한다. Alert.summarize는 재정의한 본문을 직접 사용한다. 실선은 동작 선택과 호출이며 Post에서 Alert로 이어지는 실행 화살표는 없다.기본 사용호출재정의Post.summarize()재정의 없음Summary.summarize기본 본문Post.summarize_author구현한 필수 동작Alert.summarize()재정의 있음Alert.summarize재정의 본문
기본 동작과 재정의의 호출 관계Post와 Alert는 서로 다른 두 호출 사례다. Post.summarize는 기본 Summary.summarize를 사용하고 그 본문이 Post.summarize_author를 호출한다. Alert.summarize는 재정의한 본문을 직접 사용한다. 실선은 동작 선택과 호출이며 Post에서 Alert로 이어지는 실행 화살표는 없다. 모바일의 위 세 노드는 Post 사례이고 아래 두 노드는 Alert 사례다. 서로 연결하지 않는다.기본 사용호출재정의Post · 기본 동작 사용Alert · 재정의 사용Post.summarize()재정의 없음Summary.summarize기본 본문Post의 필수 구현summarize_authorAlert.summarize()재정의 있음Alert.summarize재정의 본문
  1. 트레이트가 필수·기본 동작을 선언한다

    세미콜론 선언은 구현자가 채웁니다. 본문은 기본 동작이며 그대로 쓰거나 같은 시그니처로 재정의합니다.

  2. 트레이트와 타입의 정의 주체를 확인한다

    아래 예제에서는 트레이트나 타입 중 하나를 현재 crate가 정의하면 구현할 수 있습니다. 둘 다 외부 정의면 충돌을 막기 위해 거부됩니다.

  3. 허용된 impl이 계약을 완성한다

    필수 동작이 남으면 빈 impl은 불완전합니다. 각 동작은 기본 구현이 있거나 직접 제공되어야 계약이 성립합니다.

required and default methods

기본 동작은 필수 동작을 부를 수 있다

pub trait Summary {
    fn summarize_author(
        &self,
    ) -> String;

    fn summarize(&self) -> String {
        format!(
            "(Read more from {}...)",
            self.summarize_author(),
        )
    }
}

impl Summary for Tweet {
    fn summarize_author(
        &self,
    ) -> String {
        format!("@{}", self.username)
    }
}

Tweet은 summarize_author를 구현해 기본 summarize를 씁니다. 재정의하면 기본 본문을 부를 수 없습니다.

현재 문서의 세 예제로 읽는 구현 가능성
트레이트 정의 위치대상 타입 정의 위치예결과와 이유
현재 crate외부 crateSummary + Vec<T>allowed 현재 crate 소유 트레이트.
외부 crate현재 crateDisplay + Tweetallowed 현재 crate 소유 타입.
외부 crate외부 crateDisplay + Vec<T>rejected 외부 조합의 충돌 가능성.
local trait

Summary + Vec<T>

외부 타입이어도 이 crate의 트레이트면 허용됩니다.

local type

Display + Tweet

외부 트레이트여도 이 crate의 타입이면 허용됩니다.

both external

Display + Vec<T>

트레이트와 타입이 모두 외부라 이 crate에서는 거부됩니다.

“트레이트나 타입 중 하나는 내 것”은 직접 예제 요약입니다. 제네릭·fundamental type까지 반영한 컴파일러 coherence 검사가 최종 허용 여부를 판정합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
Post 호출

재정의가 없는 Post.summarize를 부른다.

  • 선택: Summary 기본 본문
  • 구체 타입: Post
  • Alert의 재정의와 이어 실행하는 과정이 아니다.
필수 동작 호출

기본 본문이 Post.summarize_author를 부른다.

  • 호출: Post.summarize_author
  • 필수 구현: Post가 제공
  • 기본 본문이 필수 메서드 구현을 대신해 주지 않는다.
요약 반환

author 결과로 기본 본문의 요약을 만든다.

  • 결과: 기본 본문의 요약 String
  • Alert: 독립 재정의 비교
  • 현재 Post 호출의 결과만 반환하며, 다른 타입으로 상태가 바뀌지 않는다.
src/main.rs
trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

struct Post {
    author: String,
}

struct Alert {
    author: String,
}

impl Summary for Post {
    fn summarize_author(&self) -> String {
        format!("@{}", self.author)
    }
}

impl Summary for Alert {
    fn summarize_author(&self) -> String {
        format!("@{}", self.author)
    }

    fn summarize(&self) -> String {
        format!("ALERT {}", self.author)
    }
}

fn notify_any(a: &impl Summary, b: &impl Summary) -> String {
    format!("{} / {}", a.summarize(), b.summarize())
}

fn notify_same<T: Summary>(a: &T, b: &T) -> String {
    format!("{} / {}", a.summarize(), b.summarize())
}

fn main() {
    let post = Post { author: String::from("mina") };
    let other_post = Post { author: String::from("jun") };
    let alert = Alert { author: String::from("ops") };

    println!("{}", post.summarize());
    println!("{}", alert.summarize());
    println!("{}", notify_any(&post, &alert));
    println!("{}", notify_same(&post, &other_post));
}
(Read more from @mina...)
ALERT ops
(Read more from @mina...) / ALERT ops
(Read more from @mina...) / (Read more from @jun...)

Post는 summarize를 덮어쓰지 않았으므로, 기본 본문이 Post의 summarize_author를 불러 @mina로 요약을 만듭니다. Alert는 summarize를 덮어썼으므로 기본 본문 대신 자기 구현이 불립니다. 이 트레이트와 두 타입은 모두 이 크레이트에 있으므로 구현이 허용되며, 바깥의 트레이트를 바깥의 타입에 구현하려 했다면 일관성 규칙 때문에 거부되었을 것입니다. notify_any와 notify_same은 다음 절에서 설명합니다.

매개변수로서의 트레이트

트레이트를 정의하고 구현할 줄 알게 되었으니, 트레이트로 여러 타입을 받는 함수를 정의해 봅니다. 앞에서 NewsArticle과 SocialPost에 구현한 Summary로, 매개변수 item에 summarize를 부르는 notify 함수를 정의합니다. 매개변수 item은 Summary를 구현한 어떤 타입이든 받으며, 이를 위해 구체적인 타입 대신 impl 키워드와 트레이트 이름을 씁니다.

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

이 매개변수는 지정한 트레이트를 구현한 어떤 타입이든 받습니다. notify의 본문에서는 item에 summarize처럼 Summary 트레이트의 메서드를 무엇이든 부를 수 있습니다. notify에 NewsArticle이나 SocialPost의 인스턴스를 넘겨 부를 수 있지만, String이나 i32처럼 Summary를 구현하지 않은 타입을 넘기면 컴파일되지 않습니다.

트레이트 바운드 문법

impl Trait 문법은 짧게 쓰기에 좋지만, 실제로는 트레이트 바운드(trait bound)라는 더 긴 형태의 줄임말입니다. 그 긴 형태는 다음과 같습니다.

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

이 형태는 앞의 예와 같지만 더 장황합니다. 제네릭 타입 매개변수를 선언하고 콜론 뒤에 트레이트 바운드를 적으며, 이 바운드는 꺾쇠괄호 안에 있습니다. 간단한 경우에는 impl Trait이 편하고, 복잡한 경우에는 트레이트 바운드가 더 많은 것을 나타낼 수 있습니다. 예를 들어 Summary를 구현한 매개변수 둘을 받을 때 impl Trait으로 쓰면 다음과 같습니다.

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

item1과 item2가 서로 다른 타입이어도 되고 둘 다 Summary만 구현하면 된다면 impl Trait이 알맞습니다. 하지만 두 매개변수가 같은 타입이어야 한다면 트레이트 바운드를 써야 합니다.

pub fn notify<T: Summary>(item1: &T, item2: &T) {

item1과 item2의 타입을 모두 제네릭 타입 T로 정했으므로, 두 인수의 구체적인 타입이 같아야 합니다. 앞의 예제에서 notify_any는 Post와 Alert를 함께 받을 수 있지만, notify_same은 두 Post처럼 같은 타입만 받으므로 notify_same(&post, &alert)는 컴파일되지 않습니다. 이 바운드로 제네릭 데이터 타입의 largest도 고칠 수 있습니다. 시그니처를 fn largest<T: PartialOrd>(list: &[T]) -> &T로 바꾸면, T가 >로 비교할 수 있는 타입으로 제한되어 컴파일됩니다.

+ 구문으로 트레이트 바운드를 여럿 지정하기

트레이트 바운드를 둘 이상 정할 수도 있습니다. notify가 item의 summarize를 부르면서 화면 출력 형식도 쓰게 하려면, item이 Display와 Summary를 모두 구현해야 한다고 정의에 적습니다. + 문법을 쓰면 됩니다.

pub fn notify(item: &(impl Summary + Display)) {

+는 제네릭 타입의 트레이트 바운드에도 쓸 수 있습니다. 뜻은 앞의 impl Trait 문법과 같고, 두 바운드를 타입 매개변수 T의 선언에 붙인다는 점만 다릅니다.

pub fn notify<T: Summary + Display>(item: &T) {

두 바운드를 정했으므로, notify의 본문은 summarize를 부를 수도 있고 {}로 item의 형식을 정해 출력할 수도 있습니다.

where 조항으로 트레이트 바운드 정리하기

트레이트 바운드가 너무 많으면 단점이 생깁니다. 제네릭마다 바운드가 있으므로, 제네릭 타입 매개변수가 여럿인 함수는 이름과 매개변수 목록 사이에 바운드가 길게 늘어서 시그니처를 읽기 어렵습니다. 그래서 러스트는 함수 시그니처 뒤의 where 절에 바운드를 적는 다른 문법을 제공합니다. 다음처럼 쓰는 대신

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

where 절을 쓰면 다음과 같습니다. 바운드를 꺾쇠괄호에서 빼내 시그니처 뒤의 where 아래에 타입 매개변수마다 한 줄씩 적으므로, 꺾쇠괄호에는 T와 U라는 이름만 남습니다.

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{

함수의 시그니처가 덜 어수선해져서, 함수 이름과 매개변수 목록, 반환 타입이 바운드가 많지 않은 함수처럼 가까이 붙습니다.

트레이트를 구현하는 타입을 반환하기

반환 자리에도 impl Trait을 써서, 어떤 트레이트를 구현한 타입의 값을 돌려준다고 할 수 있습니다. 아래 함수의 본문은 SocialPost 하나를 만들어 돌려주지만, 시그니처의 반환 타입에는 SocialPost라는 이름이 나오지 않습니다.

fn returns_summarizable() -> impl Summary {
    SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    }
}

반환 타입에 impl Summary를 쓰면, 구체적인 타입의 이름을 적지 않고 Summary를 구현한 어떤 타입을 돌려준다고 밝히는 것입니다. 이 함수는 SocialPost를 돌려주지만, 부르는 코드는 그 사실을 알 필요가 없습니다. 이 방법은 클로저와 반복자에서 특히 쓸모가 있습니다. 클로저와 반복자는 컴파일러만 아는 타입이나 이름이 아주 긴 타입을 만드는데, impl Trait을 쓰면 Iterator 트레이트를 구현한 어떤 타입을 돌려준다고만 적으면 됩니다.

하지만 impl Trait은 한 가지 타입만 돌려줄 때 쓸 수 있습니다. 예를 들어 반환 타입을 impl Summary로 적고 NewsArticle이나 SocialPost 가운데 하나를 돌려주는 코드는 컴파일되지 않습니다. 컴파일러가 impl Trait을 구현하는 방식의 제약 때문으로, 이런 함수를 쓰는 방법은 트레이트 객체와 다형성에서 다룹니다.

더 깊이: 반환 자리의 impl Trait이 숨기는 것과 붙잡는 것

반환 자리의 impl Trait은 Reference가 말하는 추상 반환 타입(abstract return type)입니다. 그 뒤에는 함수가 고른 구체적인 타입이 하나 있지만, 부르는 쪽은 적어 둔 트레이트의 메서드만 쓸 수 있습니다. 제네릭 매개변수는 부르는 쪽이 타입을 고르지만 반환 자리의 impl Trait은 함수가 고른다는 점에서 방향이 반대입니다. 매개변수 자리의 impl Trait도 제네릭과 완전히 같지는 않아서, 부르는 쪽이 foo::<usize>(1)처럼 터보피시로 그 타입을 적을 수 없습니다. 그래서 둘 사이를 바꾸면 제네릭 인수의 개수가 달라져, 그 함수를 부르는 코드가 깨질 수 있습니다.

숨겨진 타입이 어떤 매개변수를 담을 수 있는지, 곧 무엇을 붙잡는지(capture)는 에디션에 따라 다릅니다. 2021 에디션까지 일반 함수와 고유 impl의 메서드에서 반환 자리 impl Trait은 타입 매개변수는 늘 붙잡지만, 라이프타임은 그 impl Trait의 바운드에 직접 나타난 것만 붙잡았습니다. 그래서 &'a str을 받아 그 참조를 담은 반복자를 돌려주려면 + 'a를 적는 식의 우회가 필요했습니다. 에디션 가이드에 따르면 2024 에디션에서는 범위 안의 모든 제네릭 매개변수를 라이프타임까지 붙잡으며, Rust 1.82에 들어온 impl Sized + use<'a, T> 같은 use<..> 바운드로 붙잡을 것을 직접 고를 수 있습니다.

Rust 1.75부터는 트레이트의 메서드도 반환 타입에 impl Trait을 쓸 수 있습니다. Reference는 이것을 그 트레이트의 이름 없는 연관 타입으로 풀어 설명하므로, 트레이트를 구현하는 타입마다 서로 다른 구체적인 반환 타입을 고를 수 있습니다.

트레이트 바운드를 사용해 조건부로 메서드 구현하기

제네릭 타입 매개변수를 쓰는 impl 블록에 트레이트 바운드를 붙이면, 지정한 트레이트를 구현한 타입에만 메서드를 구현할 수 있습니다. 다음 Pair<T>는 언제나 새 Pair<T>를 돌려주는 연관 함수 new를 구현하지만, cmp_display 메서드는 안쪽 타입 T가 비교할 수 있게 하는 PartialOrd와 출력할 수 있게 하는 Display를 모두 구현할 때만 구현합니다.

use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

다른 트레이트를 구현한 모든 타입에 어떤 트레이트를 조건부로 구현할 수도 있습니다. 트레이트 바운드를 만족하는 모든 타입에 트레이트를 구현하는 것을 포괄 구현(blanket implementation)이라고 하며, 러스트 표준 라이브러리에서 많이 쓰입니다. 예를 들어 표준 라이브러리는 Display를 구현한 모든 타입에 ToString 트레이트를 구현하며, 그 impl 블록은 다음과 비슷합니다.

impl<T: Display> ToString for T {
    // --생략--
}

이 포괄 구현 덕분에 Display를 구현한 어떤 타입에든 ToString의 to_string 메서드를 부를 수 있습니다. 예를 들어 정수는 Display를 구현하므로 let s = 3.to_string();처럼 정수를 String으로 바꿀 수 있습니다. 포괄 구현은 트레이트 문서의 Implementors 절에 나옵니다.

아래 표는 이 글에서 본 바운드의 여러 형태가 각각 어떤 타입 관계와 범위를 만드는지 정리한 것입니다.

Rust 트레이트 바운드가 제네릭 본문이 사용할 능력을 컴파일 시점에 증명하고, 매개변수 impl Trait, 이름 있는 제네릭, 더하기 바운드, where 절, 반환 impl Trait, 조건부 impl과 포괄 impl에서 각각 다른 타입 관계와 API 범위를 만드는 선택표

CAPABILITY · TYPE RELATION · PLACEMENT · API

바운드는 장식이 아닌 동작 증명이다

제네릭 본문은 summarize, 비교, 출력에 필요한 동작만 요구합니다. 같은 능력 요구라도 타입 관계와 제약 위치에 따라 표기가 달라집니다.

의도부터 고르면 바운드 문법과 타입 관계가 함께 정해진다
의도대표 문법타입 관계와 사용 범위
입력별&impl Summary각 위치는 서로 다른 구체 타입을 받고, 그 인수에서 Summary 동작을 엽니다.
같은 타입<T: Summary>이름 T를 공유한 위치는 같은 구체 타입이며 함수 전체에서 그 관계를 재사용합니다.
복수 능력T: Summary + DisplayT가 두 트레이트를 모두 구현해야 요약과 출력 동작을 함께 쓸 수 있습니다.
긴 제약 정리where T: Display + Clone꺾쇠괄호 바운드와 같은 관계를 시그니처 뒤에 배치합니다.
반환 숨김-> impl Summary같은 제네릭 인스턴스의 반환 경로는 같은 숨은 구체 타입이며 호출자는 트레이트 동작만 봅니다.
조건부 APIPair<T> 조건부 implDisplay + PartialOrd여야 cmp_display가 생기고, new는 항상 남습니다.
포괄 구현Display → ToString표준 구현은 T: ?Sized까지 허용하며 coherence와 고아 규칙을 지킵니다.
independent input

&impl Summary

각 위치가 독립적인 구체 타입을 받을 수 있고 그 인수에서 Summary 동작을 엽니다.

same concrete type

<T: Summary>

같은 T 이름을 쓴 위치는 같은 구체 타입입니다.

multiple capabilities

Summary + Display

본문이 실제로 사용하는 여러 동작을 모두 요구합니다.

readable placement

where T: ...

의미는 그대로 두고 긴 제약을 where 절에 둡니다.

opaque return

-> impl Summary

같은 제네릭 인스턴스의 구체 타입은 숨겨도 모든 반환 경로의 타입은 같아야 합니다.

conditional impl

impl<T: ...> Pair<T>

cmp_display는 바운드를 충족해야 사용할 수 있습니다.

blanket impl

impl<T: ...> Trait for T

만족하는 모든 타입에 구현하므로 coherence와 충돌 규칙을 확인합니다.

conditional method set

Pair<T>의 기본 API와 조건부 API를 분리한다

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T> Pair<T>
where
    T: Display + PartialOrd,
{
    fn cmp_display(&self) {
        // bound가 비교와 출력을 허용
    }
}

new: 모든 T · cmp_display: 두 바운드 충족 T.

scope of the proof

바운드가 보장하지 않는 것까지 확대하지 않는다

바운드는 동작 사용만 허용합니다. 논리·수명·I/O 비용은 별도이며 trait object 디스패치와도 다릅니다.

impl Trait는 위치마다 다릅니다. 매개변수는 익명 타입 바운드, 반환은 한 구체 타입을 숨기는 opaque type입니다.

트레이트와 트레이트 바운드를 쓰면 제네릭 타입 매개변수로 중복을 줄이면서도, 그 제네릭 타입이 특정한 동작을 해야 한다고 컴파일러에 알릴 수 있습니다. 그러면 컴파일러가 그 정보로 코드에 쓰인 구체적인 타입이 올바른 동작을 하는지 확인합니다. 동적 타입 언어에서는 메서드를 정의하지 않은 타입의 값에 그 메서드를 부르면 실행 중에 오류가 나지만, 러스트는 이런 오류를 컴파일할 때 잡아내므로 코드를 실행하기 전에 고치게 하고, 실행 중에 그 동작이 있는지 확인하는 코드를 쓸 필요도 없게 합니다.

정리

  • 트레이트는 trait으로 메서드 시그니처를 묶어 여러 타입이 함께 갖는 동작을 정의하며, impl 트레이트 for 타입으로 구현합니다.
  • 트레이트의 메서드를 쓰려면 그 트레이트를 스코프로 가져와야 합니다.
  • 트레이트나 타입 가운데 하나는 지금 크레이트에 있어야 구현할 수 있으며, 이 고아 규칙이 구현이 겹치는 것을 막습니다.
  • 기본 구현은 그대로 쓰거나 덮어쓸 수 있고, 같은 트레이트의 다른 메서드를 부를 수 있습니다.
  • &impl Trait은 <T: Trait> 바운드의 줄임말이며, 두 매개변수가 같은 타입이어야 하면 바운드로 씁니다.
  • +로 바운드를 여럿 정하고, 바운드가 많으면 where 절로 옮깁니다.
  • 반환 자리의 impl Trait은 한 가지 타입만 돌려줄 때 쓸 수 있습니다.
  • 바운드를 붙인 impl로 메서드를 조건부로 구현하며, 바운드를 만족하는 모든 타입에 트레이트를 구현하는 것을 포괄 구현이라고 합니다.

연습 문제

1. 같은 타입이어야 할까

앞의 Post와 Alert 예제에서 notify_same(&post, &alert)를 부르면 어떻게 되나요? notify_any(&alert, &other_post)는 무엇을 출력하나요?

해설 보기

notify_same은 두 매개변수가 모두 같은 T여야 하는데 Post와 Alert는 다른 타입이므로 타입이 맞지 않는다는 오류로 컴파일되지 않습니다. notify_any는 두 매개변수가 저마다 Summary를 구현하기만 하면 되므로 컴파일되며, ALERT ops / (Read more from @jun...)을 출력합니다.

2. 구현할 수 있는 조합

aggregator 크레이트 안에서 다음 구현 가운데 허용되는 것은 무엇인가요? (가) impl Summary for Vec<String> (나) impl std::fmt::Display for SocialPost (다) impl std::fmt::Display for Vec<String>

해설 보기

(가)와 (나)는 허용됩니다. (가)는 트레이트 Summary가, (나)는 타입 SocialPost가 aggregator에 정의되어 있기 때문입니다. (다)는 Display와 Vec<String>이 모두 표준 라이브러리에 있어 aggregator의 것이 아니므로 고아 규칙에 걸려 거부됩니다.

3. 조건부 메서드

Pair::new(3, 7).cmp_display()는 무엇을 출력하나요? Vec<i32> 두 개로 만든 Pair에 cmp_display를 부를 수 있나요?

해설 보기

i32는 Display와 PartialOrd를 모두 구현하므로 부를 수 있고, 3이 7보다 작으므로 The largest member is y = 7을 출력합니다. Vec<i32>는 PartialOrd는 구현하지만 Display는 구현하지 않으므로, 그 Pair에는 cmp_display가 없어 부르면 컴파일되지 않습니다. new는 바운드가 없는 impl에 있으므로 어느 쪽이든 부를 수 있습니다.

다음 글 라이프타임과 참조 유효성에서는 참조가 필요한 동안 유효하도록 보장하는 또 다른 제네릭인 라이프타임을 살펴봅니다.