트레이트: 공통 동작 정의하기

트레이트: 공통 동작 정의하기

여러 타입이 똑같은 동작을 공유하게 만들고 싶어요. 예를 들어 어떤 타입이든 "요약을 보여줄 수 있다"는 동작이 있다면, 그 요약을 보여주는 함수를 타입마다 따로 쓰지 않고 공통으로 처리할 수 있겠죠. 그렇게 공통 동작을 정의하는 도구가 트레이트(trait)예요. 트레이트는 특정 타입이 갖고 다른 타입과 공유할 수 있는 기능을 정의하고, 트레이트 바운드(trait bound)로 제네릭 타입이 가져야 할 동작을 지정할 수 있어요. 다른 언어에서 흔히 '인터페이스(interface)'라고 부르는 것과 비슷하지만 차이점도 있어요.

트레이트 정의는 메서드 시그니처를 한데 묶어, 어떤 목적을 이루는 데 필요한 동작의 집합을 정의하는 방식이에요. 실제 구현은 두고 시그니처만 모아두는 거죠.

출처: The Rust Programming Language — Traits: Defining Shared Behavior

트레이트 정의하기

뉴스 기사 구조체 NewsArticle과 SNS 게시물 구조체 SocialPost를 담는 미디어 집계 크레이트를 만든다고 해볼게요. 각 타입에서 요약을 받아오려면, 인스턴스의 summarize 메서드를 호출하는 방식으로 요청하도록 만들고 싶어요. 공개 Summary 트레이트를 정의하면 이 동작을 표현할 수 있어요.

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

trait 키워드로 트레이트를 선언하고 그다음 트레이트 이름(Summary)을 써요. pub로 선언하면 이 크레이트에 의존하는 다른 크레이트도 이 트레이트를 쓸 수 있어요. 중괄호 안에는 이 트레이트를 구현하는 타입이 가져야 할 동작을 설명하는 메서드 시그니처를 선언하는데, 여기서는 fn summarize(&self) -> String이에요.

메서드 시그니처 뒤에는 중괄호로 구현을 넣는 대신 세미콜론을 써요. 이 트레이트를 구현하는 각 타입은 메서드 몸통에서 자기만의 동작을 제공해야 해요. 컴파일러는 Summary 트레이트를 가진 타입이 summarize 메서드를 정확히 이 시그니처로 정의했는지 강제해요. 트레이트는 메서드가 여러 개일 수 있는데, 시그니처를 한 줄에 하나씩 나열하고 각 줄을 세미콜론으로 끝내면 돼요.

타입에 트레이트 구현하기

정의한 시그니처를 가지고 두 타입에 Summary를 구현해 볼게요. NewsArticle은 헤드라인·저자·위치를 조합해 summarize의 반환 값을 만들고, SocialPost는 사용자 이름 뒤에 게시물 본문 전체를 붙이는 방식으로 정의해요(게시물 내용은 이미 280자로 제한돼 있다고 가정해요).

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 블록 안에는 트레이트 정의가 정한 메서드 시그니처를 넣고, 각 시그니처 뒤에 세미콜론 대신 중괄호로 해당 타입에 대한 메서드 동작을 채워 넣어요.

이제 크레이트 사용자들은 NewsArticleSocialPost 인스턴스에서 일반 메서드를 호출하듯 트레이트 메서드를 호출할 수 있어요. 차이는 트레이트를 스코프에 가져와야 한다는 것뿐이에요.

여기 한 가지 제약이 있어요. 트레이트를 타입에 구현할 수 있는 건, 트레이트나 타입 또는 둘 다가 우리 크레이트에 로컬일 때뿐이에요. 예를 들어 aggregator 크레이트 안에서 SocialPost라는 우리 타입에 표준 라이브러리 트레이트인 Display를 구현하는 건 가능해요(타입이 로컬이니까요). aggregator 크레이트에서 Vec<T>Summary를 구현하는 것도 가능해요(트레이트가 로컬이니까요).

하지만 외부 트레이트를 외부 타입에 구현하는 건 안 돼요. aggregator 크레이트 안에서 Vec<T>Display를 구현할 수 없는 건, DisplayVec<T>가 둘 다 표준 라이브러리에 정의되어 우리 크레이트에 로컬이 아니기 때문이에요. 이 제약은 '정합성(coherence)', 더 구체적으로는 '고아 규칙(orphan rule)'이라 불리는 속성의 일부예요. 이 규칙 덕분에 남의 코드가 여러분의 코드를 망가뜨릴 수 없고 그 반대도 마찬가지예요. 규칙이 없다면 두 크레이트가 같은 타입에 같은 트레이트를 구현했을 때 Rust가 어느 구현을 쓸지 알 수 없게 되거든요.

기본 구현 사용하기

트레이트의 일부 또는 모든 메서드에 기본 동작을 두면, 구현할 때마다 모든 메서드를 정의하지 않아도 돼서 유용해요. 트레이트를 특정 타입에 구현할 때 각 메서드의 기본 동작을 유지할지 덮어쓸지 고르면 되죠.

summarize 메서드에 기본 문자열을 지정해 볼게요.

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

NewsArticle에 트레이트만 구현하고 메서드는 덮어쓰지 않으려면 빈 impl 블록을 쓰면 돼요.

impl Summary for NewsArticle {}

기본 구현을 덮어쓰는 문법은 기본 구현이 없는 트레이트 메서드를 구현하는 문법과 같아요. 덕분에 SocialPost 구현은 그대로 두면 돼요.

기본 구현은 같은 트레이트의 다른 메서드를 호출할 수도 있는데, 그 다른 메서드에는 기본 구현이 없어도 괜찮아요. 이렇게 하면 트레이트가 많은 유용한 기능을 제공하면서 구현하는 쪽에서 정해야 하는 부분은 아주 작을 수 있어요. 예를 들어 Summary 트레이트에 구현이 필수인 summarize_author 메서드와, 그 summarize_author를 호출하는 기본 구현을 가진 summarize 메서드를 둘 수 있어요.

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를 호출해 알아서 동작하죠. 단, 덮어쓰는 구현에서 같은 메서드의 기본 구현을 호출하는 건 불가능해요.

트레이트를 매개변수로 사용하기

트레이트로 여러 타입을 받는 함수를 정의할 수도 있어요. Summary를 구현한 어떤 타입의 값이든 받아서 그 summarize 메서드를 호출하는 notify 함수를 만들어 볼게요. 이때 impl Trait 문법을 써요.

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

item 매개변수의 구체 타입 대신 impl 키워드와 트레이트 이름을 지정했어요. 이 매개변수는 지정한 트레이트를 구현한 어떤 타입이든 받아요. notify 본문에서는 Summary 트레이트에서 온 summarize 같은 메서드를 item에 호출할 수 있어요. NewsArticle이나 SocialPost 인스턴스를 notify에 넘길 수 있고, String이나 i32처럼 Summary를 구현하지 않은 타입으로 호출하면 컴파일되지 않아요.

impl Trait 문법은 단순한 경우에 편리하지만, 사실 '트레이트 바운드(trait bound)'라 불리는 더 긴 형식의 문법적 설탕(syntax sugar)이에요. 트레이트 바운드는 콜론 뒤, 꺾쇠 안에서 제네릭 타입 매개변수의 선언과 함께 지정해요.

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) {

반대로 두 매개변수가 '같은 타입'이어야 한다면 트레이트 바운드를 써야 해요.

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

item1item2의 타입으로 지정된 제네릭 타입 T는, 두 매개변수에 전달되는 값의 구체 타입이 같아야 한다고 함수를 제약해요.

여러 트레이트 바운드와 where 절

notifysummarize뿐 아니라 표시 형식(Display)도 쓰길 원한다면, + 문법으로 여러 트레이트 바운드를 지정할 수 있어요.

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

+ 문법은 제네릭 타입의 트레이트 바운드에도 유효해요.

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

트레이트 바운드가 너무 많아지면 시그니처가 읽기 어려워져요. 제네릭이 하나마다 바운드가 붙으니까요. 그래서 Rust는 함수 시그니처 뒤의 where 절 안에 트레이트 바운드를 지정하는 다른 문법도 제공해요. 한 줄에 바운드를 몰아넣는 대신, 이렇게 쓰면 함수 이름·매개변수 목록·반환 타입이 가까이 붙어 있어서 시그니처가 덜 어수선해져요.

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

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

impl Trait 문법은 반환 위치에서도 쓸 수 있어요. 어떤 타입이 트레이트를 구현한다는 것만 지정하고 구체 타입 이름은 밝히지 않은 채 값을 반환할 수 있죠.

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를 지정하면, returns_summarizable 함수가 Summary 트레이트를 구현한 어떤 타입을 반환한다는 것만 나타내요. 여기서는 SocialPost를 반환하지만, 호출하는 코드는 그걸 몰라도 돼요. 반환 타입을 "구현한 트레이트"로만 지정하는 이 기능은 클로저와 이터레이터에서 특히 유용해요. 이들은 컴파일러만 아는 타입이거나 지정하기엔 너무 긴 타입을 만들거든요. impl Trait으로 "Iterator 트레이트를 구현한 어떤 타입을 반환한다"고 간결히 표현할 수 있어요.

단, impl Trait은 단일 타입을 반환할 때만 쓸 수 있어요. NewsArticle이든 SocialPost든 상황에 따라 반환하려는 코드는 impl Summary를 반환 타입으로 써도 동작하지 않아요. 컴파일러에서 impl Trait 문법이 구현되는 방식의 제약 때문이에요. 이렇게 여러 타입을 추상화해 다루는 방법은 트레이트 객체(trait object)를 다루는 Chapter 18에서 배울 수 있어요.

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

제네릭 타입 매개변수를 쓰는 impl 블록에서 트레이트 바운드를 쓰면, 지정한 트레이트를 구현한 타입에 대해서만 메서드를 조건부로 구현할 수 있어요. 예를 들어 Pair<T> 타입은 항상 new 함수로 새 Pair<T> 인스턴스를 반환해요(impl 블록의 타입 별칭인 Self는 이 경우 Pair<T>를 가리켜요). 그런데 다음 impl 블록에서는, 내부 타입 T가 비교를 가능하게 하는 PartialOrd 트레이트와 출력을 가능하게 하는 Display 트레이트를 '모두' 구현할 때만 Pair<T>cmp_display 메서드를 가지게 돼요.

또 어떤 트레이트를 구현한 모든 타입에 대해 다른 트레이트를 조건부로 구현할 수도 있어요. 트레이트 바운드를 충족하는 모든 타입에 트레이트를 구현하는 걸 '블랭킷 구현(blanket implementation)'이라고 하고, Rust 표준 라이브러리에서 널리 쓰여요. 예를 들어 표준 라이브러리는 Display 트레이트를 구현한 모든 타입에 ToString 트레이트를 구현해요. 그 구현은 대략 이렇게 생겼어요.

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

이 블랭킷 구현 덕분에 Display를 구현한 어느 타입에서든 ToString 트레이트의 to_string 메서드를 호출할 수 있어요. 정수는 Display를 구현하므로, 정수를 대응하는 String 값으로 이렇게 바꿀 수 있어요.

let s = 3.to_string();

트레이트와 트레이트 바운드는 제네릭 타입 매개변수로 중복은 줄이면서 컴파일러에는 "제네릭 타입이 특정 동작을 갖길 원한다"고 알려줘요. 컴파일러는 트레이트 바운드 정보로 우리 코드에 쓰인 모든 구체 타입이 올바른 동작을 제공하는지 검사할 수 있어요. 동적 타입 언어라면 메서드를 정의하지 않은 타입에서 메서드를 호출했을 때 런타임 오류가 나지만, Rust는 그 오류를 컴파일 타임으로 끌어와서 코드가 실행되기 전에 문제를 고치도록 강제해요. 더불어 런타임에 동작을 검사하는 코드를 작성할 필요도 없어요. 이미 컴파일 타임에 검사했으니까요. 이로써 제네릭의 유연함을 포기하지 않으면서 성능도 올라가요.

더 알아보기 (Learn more)