트레잇: 공유 동작을 정의하기

트레잇은 다른 종류의 추상화를 사용할 수 있도록 해줍니다: 이는 타입들이 공통적으로 갖는 동작에 대하여 추상화하도록 해줍니다. 트레잇(trait) 이란 러스트 컴파일러에게 특정한 타입이 갖고 다른 타입들과 함께 공유할 수도 있는 기능에 대해 말해줍니다. 우리가 제네릭 타입 파라미터를 사용하는 상황에서는, 컴파일 타임에 해당 제네릭 타입이 어떤 트레잇을 구현한 타입이어야 함을 명시하여, 그러한 상황에서 우리가 사용하길 원하는 동작을 갖도록 하기 위해 트레잇 바운드(trait bounds) 를 사용할 수 있습니다.

노트: 트레잇은 다른 언어들에서 '인터페이스(interface)'라고 부르는 기능과 유사하지만, 몇 가지 다른 점이 있습니다.

트레잇 정의하기

어떤 타입의 동작은 우리가 해당 타입 상에서 호출할 수 있는 메소드들로 구성되어 있습니다. 만일 우리가 서로 다른 타입에 대해 모두 동일한 메소드를 호출할 수 있다면 이 타입들은 동일한 동작을 공유하는 것입니다. 트레잇의 정의는 어떠한 목적을 달성하기 위해 필요한 동작의 집합을 정의하기 위해 메소드 시그니처들을 함께 묶는 방법입니다.

예를 들면, 다양한 종류와 양의 텍스트를 갖는 여러 가지의 구조체를 가지고 있다고 칩시다: NewsArticle 구조체는 세계의 특정한 곳에서 줄지어 들어오는 뉴스 이야기를 들고 있고, Tweet은 최대 140글자의 콘텐츠와 함께 해당 트윗이 리트윗인지 혹은 다른 트윗에 대한 답변인지와 같은 메타데이터를 가지고 있습니다.

우리는 NewsArticle 혹은 Tweet 인스턴스에 저장되어 있을 데이터에 대한 종합 정리를 보여줄 수 있는 미디어 종합기 라이브러리를 만들고 싶어 합니다. 각각의 구조체들이 가질 필요가 있는 동작은 정리해주기가 되어야 하며, 그래서 각 인스턴스 상에서 summary 메소드를 호출함으로써 해당 정리를 얻어낼 수 있어야 한다는 것입니다. Listing 10-11은 이러한 개념을 표현한 Summarizable 트레잇의 정의를 나타냅니다:

Filename: lib.rs


# #![allow(unused_variables)]
#fn main() {
pub trait Summarizable {
    fn summary(&self) -> String;
}
#}

Listing 10-11: summary 메소드에 의해 제공되는 동작으로 구성된 Summarizable 트레잇의 정의

trait 키워드 다음 트레잇의 이름, 위의 경우 Summarizable을 써서 트레잇을 선언했습니다. 중괄호 내에서는 이 트레잇을 구현하는 타입들이 가질 필요가 있는 동작들을 묘사한 메소드 시그니처들을 정의했는데, 위의 경우에는 fn summary(&self) -> String입니다. 메소드 시그니처 뒤에, 중괄호 내의 정의부를 제공하는 대신, 세미콜론을 집어넣었습니다. 그러면 이 트레잇을 구현하는 각 타입은 이 메소드의 본체에 대한 해당 타입 고유의 커스텀 동작을 제공해야 하는데, 컴파일러는 Summarizable 트레잇을 갖는 어떠한 타입이든 그에 대한 메소드 summary를 정확히 동일한 시그니처로 정의되도록 강제할 것입니다.

트레잇은 한 줄 당 하나의 메소드 시그니처와 각 줄의 끝에 세미콜론을 갖도록 함으로써, 본체 내에 여러 개의 메소드를 가질 수 있습니다.

특정 타입에 대한 트레잇 구현하기

Summariable 트레잇을 정의하였으니, 이제 우리의 미디어 종합기 내에서 이 동작을 갖길 원했던 타입들 상에 이 트레잇을 구현할 수 있습니다. Listing 10-12는 summary의 반환 값을 만들기 위해 헤드라인, 저자, 위치를 사용하는 NewsArticle 구조체 상의 Summariable 트레잇 구현을 보여줍니다. Tweet 구조체에 대해서는, 트윗 내용이 이미 140자로 제한되어 있음을 가정하고, summary를 정의하는 데 있어 사용자 이름과 해당 트윗의 전체 텍스트를 가지고 오는 선택을 했습니다.

Filename: lib.rs


# #![allow(unused_variables)]
#fn main() {
# pub trait Summarizable {
#     fn summary(&self) -> String;
# }
#
pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

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

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summarizable for Tweet {
    fn summary(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
#}

Listing 10-12: NewsArticleTweet 타입 상에서의 Summarizable 트레잇 구현

어떤 타입 상에서의 트레잇 구현은 트레잇과 관련이 없는 메소드를 구현하는 것과 유사합니다. 다른 점은 impl 뒤에 우리가 구현하고자 하는 트레잇 이름을 넣고, 그다음 for와 우리가 트레잇을 구현하고자 하는 타입의 이름을 쓴다는 것입니다. impl 블록 내에서는 트레잇 정의부가 정의한 바 있는 메소드 시그니처를 집어넣지만, 각 시그니처의 끝에 세미콜론을 집어넣는 대신 중괄호를 넣고 우리가 트레잇의 메소드가 특정한 타입에 대해서 갖기를 원하는 특정한 동작으로 메소드의 본체를 채웁니다.

트레잇을 한번 구현했다면, 트레잇의 일부가 아닌 메소드들을 호출했던 것과 동일한 방식으로 NewsArticleTweet의 인스턴스 상에서 해당 메소드들을 호출할 수 있습니다:

let tweet = Tweet {
    username: String::from("horse_ebooks"),
    content: String::from("of course, as you probably already know, people"),
    reply: false,
    retweet: false,
};

println!("1 new tweet: {}", tweet.summary());

이 코드는 1 new tweet: horse_ebooks: of course, as you probably already know, people를 출력할 것입니다.

Listing 10-12에서 Summarizable 트레잇과 NewsArticleTweet 타입을 동일한 lib.rs 내에 정의했기 때문에, 이들이 모두 동일한 스코프 내에 있다는 점을 주목하세요. 만일 이 lib.rsaggregator라고 불리는 크레이트에 대한 것이고 누군가가 우리의 크레이트 기능에 더해 그들의 WeatherForecast 구조체에 대하여 Summarizable을 구현하기를 원한다면, 그들의 코드는 Listing 10-13과 같이 이를 구현하기 전에 먼저 Summarizable 트레잇을 그들의 스코프로 가져올 필요가 있습니다:

Filename: lib.rs

extern crate aggregator;

use aggregator::Summarizable;

struct WeatherForecast {
    high_temp: f64,
    low_temp: f64,
    chance_of_precipitation: f64,
}

impl Summarizable for WeatherForecast {
    fn summary(&self) -> String {
        format!("The high will be {}, and the low will be {}. The chance of
        precipitation is {}%.", self.high_temp, self.low_temp,
        self.chance_of_precipitation)
    }
}

Listing 10-13: 우리의 aggregator 크레이트로부터 다른 크레이트 내의 스코프로 Summarizable 트레잇을 가져오기

이 코드는 또한 Summariable이 공개 트레잇임을 가정하는데, 이는 Listing 10-11에서 trait 전에 pub 키워드를 집어넣었기 때문입니다.

트레잇 구현과 함께 기억할 한 가지 제한사항이 있습니다: 트레잇 혹은 타입이 우리의 크레이트 내의 것일 경우에만 해당 타입에서의 트레잇을 정의할 수 있습니다. 바꿔 말하면, 외부의 타입에 대한 외부 트레잇을 구현하는 것은 허용되지 않습니다. 예를 들어, Vec에 대한 Display 트레잇은 구현이 불가능한데, DisplayVec 모두 표준 라이브러리 내에 정의되어 있기 때문입니다. 우리의 aggregator 크레이트 기능의 일부로서 Tweet과 같은 커스텀 타입에 대한 Display와 같은 표준 라이브러리 트레잇을 구현하는 것은 허용됩니다. 또한 우리의 aggregator 크레이트 내에서 Vec에 대한 Summarizable을 구현하는 것도 가능한데, 이는 우리 크레이트 내에 Summarizable이 정의되어 있기 때문입니다. 이러한 제한은 고아 규칙(orphan rule) 이라고 불리는 것의 일부인데, 이는 타입 이론에 흥미가 있다면 찾아볼 수 있습니다. 간단하게 말하면, 부모 타입이 존재하지 않기 때문에 고아 규칙이라고 부릅니다. 이 규칙이 없다면, 두 크레이트는 동일한 타입에 대해 동일한 트레잇을 구현할 수 있게 되고, 이 두 구현체가 충돌을 일으킬 것입니다: 러스트는 어떤 구현을 이용할 것인지 알지 못할 것입니다. 러스트가 고아 규칙을 강제하기 때문에, 다른 사람의 코드는 여러분의 코드를 망가뜨리지 못하고 반대의 경우도 마찬가지입니다.

기본 구현

종종 모든 타입 상에서의 모든 구현체가 커스텀 동작을 정의하도록 하는 대신, 트레잇의 몇몇 혹은 모든 메소드들에 대한 기본 동작을 갖추는 것이 유용할 수 있습니다. 특정한 타입에 대한 트레잇을 구현할 때, 각 메소드의 기본 동작을 유지하거나 오버라이드(override)하도록 선택할 수 있습니다.

Listing 10-14는 우리가 Listing 10-11에서 한 것과 같이 메소드 시그니처를 정의만 하는 선택 대신 Summarizable 트레잇의 summary 메소드에 대한 기본 스트링을 명시하는 선택을 하는 방법을 보여줍니다:

Filename: lib.rs


# #![allow(unused_variables)]
#fn main() {
pub trait Summarizable {
    fn summary(&self) -> String {
        String::from("(Read more...)")
    }
}
#}

Listing 10-14: summary 메소드의 기본 구현을 포함한 Summarizable 트레잇의 정의

만일 우리가 Listing 10-12에서 한 것과 같은 커스텀 구현을 정의하는 대신 NewsArticle의 인스턴스를 정리하기 위해 이 기본 구현을 사용하고자 한다면, 빈 impl 블록을 명시하면 됩니다:

impl Summarizable for NewsArticle {}

비록 NewsArticle에 대한 summary 메소드를 직접 정의하는 선택을 더 이상 하지 않았더라도, summary 메소드가 기본 구현을 갖고 있고 NewsArticleSummariable 트레잇을 구현하도록 명시했기 때문에, 우리는 여전히 newsArticle의 인스턴스 상에서 summary 메소드를 호출할 수 있습니다:

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.summary());

위의 코드는 New article available! (Read more...)를 출력합니다.

Summarizable 트레잇이 summary 에대한 기본 구현을 갖도록 변경하는 것은 Listing 10-12의 Tweet이나 Listing 10-13의 WeatherForecast 상에서의 Summarizable 구현에 대한 어떤 것도 바꾸도록 요구하지 않습니다: 기본 구현을 오버라이딩 하기 위한 문법은 기본 구현이 없는 트레잇 메소드를 구현하기 위한 문법과 정확히 동일합니다.

기본 구현은 동일한 트레잇 내의 다른 메소드들을 호출하는 것이 허용되어 있는데, 심지어 그 다른 메소드들이 기본 구현을 갖고 있지 않아도 됩니다. 이러한 방식으로, 트레잇은 수많은 유용한 기능을 제공하면서도 다른 구현자들이 해당 트레잇의 작은 일부분만 구현하도록 요구할 수 있습니다. 우리는 Summarizable 트레잇이 구현이 필요한 author_summary 메소드도 갖도록 하여, summary 메소드가 author_summary 메소드를 호출하는 기본 구현을 갖는 형태를 선택할 수도 있습니다:


# #![allow(unused_variables)]
#fn main() {
pub trait Summarizable {
    fn author_summary(&self) -> String;

    fn summary(&self) -> String {
        format!("(Read more from {}...)", self.author_summary())
    }
}
#}

이 버전의 Summarizable을 사용하기 위해서는, 어떤 타입에 대한 이 트레잇을 구현할 때 author_summary만 정의하면 됩니다:

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

일단 author_summary를 정의하면, Tweet 구조체의 인스턴스 상에서 summary를 호출할 수 있으며, summary의 기본 구현이 우리가 제공한 author_summary의 정의부를 호출할 것입니다.

let tweet = Tweet {
    username: String::from("horse_ebooks"),
    content: String::from("of course, as you probably already know, people"),
    reply: false,
    retweet: false,
};

println!("1 new tweet: {}", tweet.summary());

위의 코드는 1 new tweet: (Read more from @horse_ebooks...)를 출력할 것입니다.

오버라이딩된 구현으로부터 기본 구현을 호출하는 것은 불가능하다는 점을 기억해주세요.

트레잇 바운드

이제 트레잇을 정의하고 어떤 타입들에 대해 이 트레잇을 구현해봤으니, 제네릭 타입 파라미터를 이용하는 트레잇을 사용할 수 있습니다. 우리는 제네릭 타입에 제약을 가하여 이 제네릭 타입이 어떠한 타입이든 되기 보다는, 이 제네릭 타입이 특정한 트레잇을 구현하여 이 타입들이 가지고 있을 필요가 있는 동작을 갖고 있도록 타입들로 제한함을 컴파일러가 확신하도록 할 수 있습니다.

예를 들면, Listing 10-12에서는 NewsArticleTweet 타입에 대하여 Summarizable 트레잇을 구현했습니다. 우리는 파라미터 item 상에서 summary 메소드를 호출하는 함수 notify를 정의할 수 있는데, 이 item은 제네릭 타입 T의 값입니다. 에러없이 item 상에서 summary를 호출하기 위해서는, T에 대한 트레잇 바운드를 사용하여 itemSummarizable 트레잇을 반드시 구현한 타입이어야 함을 특정할 수 있습니다:

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

트레잇 바운드는 제네릭 타입 파라미터의 선언부와 함께, 꺾쇠 괄호 내에 콜론 뒤에 옵니다. T 상에서의 트레잇 바운드이므로, 우리는 notify를 호출하여 NewsArticle이나 Tweet의 어떠한 인스턴스라도 넘길 수 있습니다. 우리의 aggregator 크레이트를 사용하는 Listing 10-13의 외부 코드도 우리의 notify 함수를 호출하여 WeatherForecast의 인스턴스를 넘길 수 있는데, 이는 WeatherForecast 또한 Summariable을 구현하였기 때문입니다. String이나 i32 같은 어떠한 다른 타입을 가지고 notify를 호출하는 코드는 컴파일되지 않을 것인데, 그 이유는 그러한 타입들이 Summarizable을 구현하지 않았기 때문입니다.

+를 이용하면 하나의 제네릭 타입에 대해 여러 개의 트레잇 바운드를 특정할 수 있습니다. 만일 함수 내에서 타입 T에 대해 summary 메소드 뿐만 아니라 형식화된 출력을 사용하길 원한다면, 트레잇 바운드 T: Summarizable + Display를 이용할 수 있습니다. 이는 TSummariableDisplay 둘다 구현한 어떤 타입이어야 함을 의미합니다.

여러 개의 제네릭 타입 파라미터를 가진 함수들에 대하여, 각 제네릭은 고유의 트레잇 바운드를 가집니다. 함수 이름과 파라미터 리스트 사이의 꺾쇠 괄호 내에 많은 수의 트레잇 바운드 정보를 특정하는 것은 코드를 읽기 힘들게 만들 수 있으므로, 함수 시그니처 뒤에 where 절 뒤로 트레잇 바운드를 옮겨서 특정하도록 해주는 대안 문법이 있습니다. 따라서 아래와 같은 코드 대신:

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

where 절을 이용하여 아래와 같이 작성할 수 있습니다:

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

함수 이름, 파라미터 리스트, 그리고 반환 타입이 서로 가까이 있도록 하여, 이쪽이 덜 어수선하고 이 함수의 시그니처를 많은 트레잇 바운드를 가지고 있지 않은 함수처럼 보이도록 만들어 줍니다.

트레잇 바운드를 사용하여 largest 함수 고치기

따라서 여러분이 어떤 제네릭 상에서 어떤 트레잇으로 정의된 동작을 이용하기를 원하는 어떤 경우이든, 여러분은 해당 제네릭 타입 파라미터의 타입내에 트레잇 바운드를 명시할 필요가 있습니다. 이제 우리는 Listing 10-5에서 제네릭 타입 파라미터를 사용하는 largest 함수의 정의를 고칠 수 있습니다! 우리가 그 코드를 치워뒀을 때, 아래와 같은 에러를 봤었지요:

error[E0369]: binary operation `>` cannot be applied to type `T`
  |
5 |         if item > largest {
  |            ^^^^
  |
note: an implementation of `std::cmp::PartialOrd` might be missing for `T`

largest의 본체 내에서 큰 부등호 연산자를 사용하여 타입 T의 두 값을 비교할 수 있길 원했습니다. 이 연산자는 표준 라이브러리 트레잇인 std::cmp::PartialOrd 상에서 기본 메소드로 정의되어 있습니다. 따라서 큰 부등호 연산자를 사용할 수 있도록 하기 위해서는, T에 대한 트레잇 바운드 내에 PartialOrd를 특정하여 largest 함수가 비교 가능한 어떤 타입의 슬라이스에 대해 작동하도록 할 필요가 있습니다. PartialOrd는 프렐루드(prelude)에 포함되어 있기 때문에 따로 스코프 내로 가져올 필요는 없습니다.

fn largest<T: PartialOrd>(list: &[T]) -> T {

이 코드를 컴파일하면, 다른 에러를 얻게 됩니다:

error[E0508]: cannot move out of type `[T]`, a non-copy array
 --> src/main.rs:4:23
  |
4 |     let mut largest = list[0];
  |         -----------   ^^^^^^^ cannot move out of here
  |         |
  |         hint: to prevent move, use `ref largest` or `ref mut largest`

error[E0507]: cannot move out of borrowed content
 --> src/main.rs:6:9
  |
6 |     for &item in list.iter() {
  |         ^----
  |         ||
  |         |hint: to prevent move, use `ref item` or `ref mut item`
  |         cannot move out of borrowed content

이 에러에 대한 열쇠는 cannot move out of type [T], a non-copy array에 있습니다. largest 함수의 제네릭 없는 버전에서, 우리는 고작 가장 큰 i32 혹은 char를 찾는 시도만 했습니다. 4장에서 논의한 바와 같이, 고정된 크기를 갖는 i32char와 같은 타입들은 스택에 저장될 수 있으며, 따라서 이 타입들은 Copy 트레잇을 구현하고 있습니다. 우리가 largest 함수를 제네릭으로 바꿨을 때, 이제는 list 파라미터가 Copy 트레잇을 구현하지 않은 타입을 가질 가능성도 생기는데, 이는 곧 list[0]의 값을 largest 변수로 소유권을 옮기지 못할 것이라는 의미입니다.

만약 이 코드를 오직 Copy가 구현된 타입들을 가지고 호출하도록 하는 것만 원한다면, T의 트레잇 바운드에 Copy를 추가할 수 있습니다! Listing 10-15는 largest로 넘겨지는 슬라이스 내의 값의 타입이 i32char처럼 PartialOrdCopy 트레잇 모두를 구현했을 때에 한하여 컴파일되는 제네릭 largest 함수의 완전체 코드를 보여줍니다:

Filename: src/main.rs

use std::cmp::PartialOrd;

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let result = largest(&numbers);
    println!("The largest number is {}", result);

    let chars = vec!['y', 'm', 'a', 'q'];

    let result = largest(&chars);
    println!("The largest char is {}", result);
}

Listing 10-15: PartialOrdCopy 트레잇을 구현한 어떠한 제네릭 타입 상에서 동작하는 largest 함수의 동작 가능한 정의

만일 우리의 largest 함수를 Copy 트레잇을 구현한 타입에 대한 것으로만 제한하길 원치 않는다면, TCopy 대신 Clone 트레잇 바운드를 갖도록 명시하여 largest 함수가 소유권을 갖길 원하는 경우 슬라이스의 각 값이 복제되도록 할 수도 있습니다. 그러나 clone 함수를 이용한다는 것은 더 많은 힙 할당을 할 수 있다는 것이고, 힙 할당은 많은 양의 데이터에 대해서 동작할 경우 느릴 수 있습니다. largest를 구현하는 또다는 방법은 함수가 슬라이스 내의 T 값에 대한 참조자를 반환하도록 하는 것입니다. 만약 반환 타입을 T 대신 &T로 바꾸고 함수의 본체가 참조자를 반환하도록 바꾼다면, Clone이나 Copy 트레잇 바운드도 필요치 않으며 어떠한 힙 할당도 하지 않게 될 것입니다. 여러분이 직접 이 대안 해결책을 구현해보세요!

트레잇과 트레잇 바운드는 중복을 제거하기 위하여 제네릭 타입 파라미터를 사용하는 코드를 작성할 수 있도록 해주지만, 여전히 컴파일러에게 해당 제네릭 타입이 어떤 동작을 할 필요가 있는지를 정확히 명시하도록 해줍니다. 컴파일러에게 트레잇 바운드를 제공하기 때문에, 우리 코드와 함께 이용되는 모든 구체적인 타입들이 정확한 동작을 제공하는지를 확인할 수 있습니다. 동적 타입 언어에서는, 어떤 타입에 대해 어떤 메소드를 호출하는 시도를 했는데 해당 타입이 그 메소드를 구현하지 않았다면, 런타임에 에러를 얻게 됩니다. 러스트는 이러한 에러들을 컴파일 타임으로 옮겨서 우리의 코드가 실행 가능하기 전에 그 문제들을 해결하도록 우리를 강제합니다. 이에 더해서, 우리는 런타임에 해당 동작에 대한 검사를 하는 코드를 작성할 필요가 없는데, 우리는 이미 컴파일 타임에 이를 확인했기 때문이며, 이는 제네릭의 유연성을 포기하지 않고도 다른 언어들에 비해 성능을 향상시킵니다.

우리가 심지어 아직 알아채지도 못한 라이프타임(lifetime) 이라 불리는 또다른 종류의 제네릭이 있습니다. 라이프타임은 어떤 타임이 우리가 원하는 동작을 갖도록 확신하는데 도움을 주기 보다는, 참조자들이 우리가 원하는 만큼 오랫동안 유효한지를 확신하도록 도와줍니다. 라이프타임이 어떤 식으로 그렇게 하는지를 배워봅시다.