트레이트(Trait)로 공유 동작 정의하기
트레이트(Trait)로 공유 동작 정의하기
어떤 타입이 어떤 메서드를 가져야 하는지를 하나로 묶어서 정의하는 방법이 있어요. 바로 **트레이트(Trait)**죠. 트레이트 공통 동작을 다른 타입들이 함께 쓰도록 해 주는 장치예요. 이번 글에서는 트레이트를 정의하고, 타입에 구현하고, 기본 구현을 주고, 함수의 파라미터나 반환 타입으로 쓰는 방법까지 하나씩 짚어 볼게요.
출처: Rust 공식문서
본문
트레이트란 무엇인가요
트레이트는 특정 타입이 갖고 있고 다른 타입과 공유할 수 있는 기능을 정의해요. 트레이트를 쓰면 공유 동작을 추상적인 방식으로 정의할 수 있고, **트레이트 바운드(trait bound)**를 쓰면 제네릭 타입을 "이런 동작을 가진 어떤 타입"으로 한정할 수 있어요.
참고: 트레이트는 다른 언어에서 흔히 **인터페이스(interface)**라고 부르는 기능과 비슷하지만, 몇 가지 차이가 있어요.
트레이트 정의하기
타입의 동작은 우리가 그 타입에 호출할 수 있는 메서드들로 이루어져요. 여러 타입에 같은 메서드를 호출할 수 있다면 그 타입들은 같은 동작을 공유한다고 볼 수 있어요. 트레이트 정의는 이런 메서드 시그니처들을 한데 묶어서, 어떤 목적을 달성하는 데 필요한 동작들의 집합을 정의하는 방법이에요.
예를 들어, 다양한 종류와 양의 텍스트를 담는 struct들이 있다고 해 볼게요. 특정 위치에 기록된 뉴스 기사를 담는 NewsArticle struct와, 최대 280자까지 담을 수 있고 새 글인지 리포스트인지 답글인지를 나타내는 메타데이터를 함께 갖는 SocialPost struct가 있다고요.
이 둘을 대상으로 NewsArticle이나 SocialPost 인스턴스에 저장된 데이터의 요약을 보여 줄 수 있는 미디어 수집 라이브러리 크레이트 aggregator를 만들고 싶다고 해 볼게요. 그러려면 각 타입에서 요약을 얻을 수 있어야 하고, 인스턴스에서 summarize 메서드를 호출해서 그 요약을 요청할 거예요. 이 동작을 담은 공개 트레이트 Summary를 정의한 게 아래 코드예요.
pub trait Summary {
fn summarize(&self) -> String;
}
여기서는 trait 키워드 뒤에 트레이트 이름, 이 경우에는 Summary를 적어서 트레이트를 선언해요. 트레이트도 pub으로 선언했는데, 이래야 이 크레이트에 의존하는 다른 크레이트들도 이 트레이트를 쓸 수 있어요. 중괄호 안에는 이 트레이트를 구현하는 타입들이 가져야 할 동작을 설명하는 메서드 시그니처를 선언해요. 여기서는 fn summarize(&self) -> String;이죠.
메서드 시그니처 뒤에는 중괄호로 구현을 넣는 대신 세미콜론을 붙여요. 이 트레이트를 구현하는 각 타입은 메서드 본문에 자기만의 동작을 반드시 제공해야 해요. 컴파일러가 Summary 트레이트를 가진 타입은 정확히 이 시그니처의 summarize 메서드를 갖도록 강제해 줘요.
트레이트 본문에는 메서드를 여러 개 둘 수도 있어요. 메서드 시그니처를 한 줄에 하나씩 나열하고, 각 줄은 세미콜론으로 끝나요.
타입에 트레이트 구현하기
Summary 트레이트 메서드의 시그니처를 정의했으니, 이제 미디어 수집기의 타입들에 실제로 트레이트를 구현할 차례예요. NewsArticle struct에 Summary 트레이트를 구현하면서 headline, author, location을 조합해 summarize의 반환값을 만든 코드가 아래예요. SocialPost struct에서는 username 뒤에 글 전체를 이어 붙인 값을 summarize로 정의해요. 글 내용이 이미 280자로 제한돼 있다고 가정하는 거예요.
pub trait Summary {
fn summarize(&self) -> String;
}
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에 Summary 트레이트를 구현했으니, 이제 크레이트 사용자들은 NewsArticle과 SocialPost 인스턴스에서 평범한 메서드처럼 트레이트 메서드를 호출할 수 있어요. 차이는 타입뿐 아니라 트레이트도 스코프로 가져와야 한다는 점뿐이에요. 바이너리 크레이트가 aggregator 라이브러리 크레이트를 쓰는 예시를 볼게요:
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 트레이트를 스코프로 가져와 자기 타입에 구현할 수 있어요. 다만 중요한 제약이 하나 있어요. 트레이트나 타입 중 적어도 하나는 우리 크레이트에 로컬이어야 트레이트를 구현할 수 있답니다. 예를 들어 표준 라이브러리의 Display 같은 트레이트를 SocialPost 같은 우리 타입에 구현하는 건 가능해요. SocialPost 타입이 aggregator 크레이트에 로컬이니까요. 또 Summary 트레이트는 aggregator 크레이트에 로컬이므로, aggregator 크레이트 안에서 Summary를 Vec<T>에 구현할 수도 있어요.
하지만 외부 트레이트를 외부 타입에 구현하는 건 불가능해요. 예를 들어 aggregator 크레이트 안에서 Display 트레이트를 Vec<T>에 구현할 수 없어요. Display와 Vec<T> 둘 다 표준 라이브러리에 정의되어 있고 aggregator 크레이트에 로컬이 아니기 때문이에요. 이 제약은 coherence라는 속성의 일부이고, 더 정확히는 **고아 규칙(orphan rule)**이라고 불러요. 부모 타입이 여기에 없어서 이런 이름이 붙었죠. 이 규칙 덕분에 다른 사람의 코드가 우리 코드를 망가뜨릴 수 없고, 반대도 성립해요. 이 규칙이 없다면 두 크레이트가 같은 타입에 같은 트레이트를 구현하게 되어 Rust가 어느 구현을 써야 할지 알 수 없는 상황이 생길 수 있어요.
기본 구현(Default Implementation) 사용하기
모든 타입에서 모든 메서드를 구현하게 강제하는 대신, 트레이트의 메서드 일부나 전부에 기본 동작을 주면 편리할 때가 있어요. 그러면 특정 타입에 트레이트를 구현할 때 각 메서드의 기본 동작을 유지할 수도, 덮어쓸 수도 있어요.
아래 예시(리스팅 10-14)에서는 트레이트 메서드 시그니처만 정의했던 리스팅 10-12와 달리, Summary 트레이트의 summarize 메서드에 기본 문자열을 지정해요.
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {}
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)
}
}
기본 구현을 사용해서 NewsArticle 인스턴스의 요약을 얻으려면 impl Summary for NewsArticle {}처럼 빈 impl 블록을 지정하면 돼요.
NewsArticle에서 summarize 메서드를 직접 정의하지 않더라도, 기본 구현을 제공했고 NewsArticle이 Summary 트레이트를 구현하도록 지정했으니 여전히 NewsArticle 인스턴스에서 summarize 메서드를 호출할 수 있어요. 이렇게요:
use aggregator::{self, NewsArticle, Summary};
fn main() {
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...)을 출력해요.
기본 구현을 만든다고 해서 리스팅 10-13의 SocialPost에 대한 Summary 구현을 바꿀 필요는 없어요. 기본 구현을 덮어쓰는 문법이, 기본 구현이 없는 트레이트 메서드를 구현하는 문법과 똑같기 때문이에요.
기본 구현은 같은 트레이트 안의 다른 메서드를 호출할 수도 있어요. 그 다른 메서드에 기본 구현이 없어도 괜찮아요. 이렇게 하면 트레이트가 많은 유용한 기능을 제공하면서, 구현자는 그중 아주 일부분만 지정하면 되게 만들 수 있어요. 예를 들어 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())
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
이 버전의 Summary를 쓰려면 타입에 트레이트를 구현할 때 summarize_author만 정의하면 돼요:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
pub struct SocialPost {
pub username: String,
pub content: String,
pub reply: bool,
pub repost: bool,
}
impl Summary for SocialPost {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
summarize_author를 정의하고 나면 SocialPost 인스턴스에서 summarize를 호출할 수 있고, summarize의 기본 구현이 우리가 제공한 summarize_author 정의를 호출해요. summarize_author를 구현했기 때문에 Summary 트레이트가 summarize 메서드의 동작을, 코드를 더 쓰지 않아도 우리에게 제공해 주는 거예요. 그 모습을 볼게요:
use aggregator::{self, 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: (Read more from @horse_ebooks...)을 출력해요.
참고로, 덮어쓴 구현에서는 같은 메서드의 기본 구현을 호출하는 게 불가능해요.
트레이트를 파라미터로 사용하기
이제 트레이트를 정의하고 구현하는 방법을 알았으니, 트레이트를 이용해서 여러 다양한 타입을 받는 함수를 정의하는 방법을 살펴볼게요. 리스팅 10-13에서 NewsArticle과 SocialPost 타입에 구현했던 Summary 트레이트를 써서, Summary 트레이트를 구현하는 어떤 타입을 받는 item 파라미터에서 summarize 메서드를 호출하는 notify 함수를 정의해 볼게요. 이때 impl Trait 문법을 써요:
pub trait Summary {
fn summarize(&self) -> String;
}
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)
}
}
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
item 파라미터에 구체적인 타입 대신 impl 키워드와 트레이트 이름을 지정했어요. 이 파라미터는 지정한 트레이트를 구현하는 어떤 타입이든 받을 수 있어요. notify 본문에서는 Summary 트레이트에서 온 메서드, 이를테면 summarize 같은 것을 item에서 호출할 수 있어요. 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는, item1과 item2에 인자로 전달된 값의 구체적인 타입이 같아야 한다고 함수를 제한해요.
여러 트레이트 바운드: + 문법
트레이트 바운드는 여러 개 지정할 수도 있어요. notify가 item에서 summarize뿐 아니라 화면 표시용 포매팅도 쓸 수 있게 하고 싶다고 해 볼게요. 그러면 notify 정의에서 item이 Display와 Summary를 둘 다 구현해야 한다고 지정해야 하는데, 이때 + 문법을 써요:
pub fn notify(item: &(impl Summary + Display)) {
+ 문법은 제네릭 타입의 트레이트 바운드에서도 유효해요:
pub fn notify<T: Summary + Display>(item: &T) {
트레이트 바운드 두 개를 지정했으니 notify 본문에서 summarize를 호출하고 {}로 item을 포매팅할 수 있어요.
where 절로 트레이트 바운드 더 읽기 좋게
트레이트 바운드를 너무 많이 쓰면 단점이 생겨요. 각 제네릭마다 트레이트 바운드가 따로 있으니, 제네릭 타입 파라미터가 여럿인 함수는 함수 이름과 파라미터 목록 사이에 트레이트 바운드 정보가 잔뜩 끼어들어서 시그니처를 읽기 어렵게 만들 수 있어요. 그래서 Rust는 트레이트 바운드를 함수 시그니처 뒤의 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,
{
unimplemented!()
}
이 함수의 시그니처는 훨씬 덜 어수선해요. 함수 이름, 파라미터 목록, 반환 타입이 트레이트 바운드가 많이 없는 함수처럼 서로 가까이 붙어 있죠.
트레이트를 구현하는 타입을 반환하기
impl Trait 문법은 반환 위치에서도 쓸 수 있어요. 트레이트를 구현하는 어떤 타입의 값을 반환하도록 지정하는 거죠. 이렇게요:
pub trait Summary {
fn summarize(&self) -> String;
}
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)
}
}
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 트레이트를 구현하는 어떤 타입을 반환한다고 지정하게 돼요. 이 경우 returns_summarizable은 SocialPost를 반환하지만, 이 함수를 호출하는 코드는 그걸 알 필요가 없어요.
반환 타입을 그 타입이 구현하는 트레이트로만 지정할 수 있다는 점은 클로저와 이터레이터 맥락에서 특히 유용해요. 이 둘은 13장에서 다룰 거예요. 클로저와 이터레이터는 컴파일러만 알거나, 길게 적어야 하는 타입을 만들어내거든요. impl Trait 문법을 쓰면 함수가 Iterator 트레이트를 구현하는 어떤 타입을 반환한다고 아주 간결하게 지정할 수 있어서, 아주 긴 타입을 다 적지 않아도 돼요.
다만 impl Trait은 단일 타입만 반환할 때 쓸 수 있어요. 반환 타입을 impl Summary로 지정한 아래 코드처럼 NewsArticle이나 SocialPost를 상황에 따라 반환하는 건 동작하지 않아요:
pub trait Summary {
fn summarize(&self) -> String;
}
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)
}
}
fn returns_summarizable(switch: bool) -> impl Summary {
if switch {
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.",
),
}
} else {
SocialPost {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
repost: false,
}
}
}
NewsArticle이나 SocialPost 중 하나를 반환하는 건, 컴파일러에서 impl Trait 문법을 구현하는 방식의 제약 때문에 허용되지 않아요. 이런 동작을 하는 함수를 어떻게 쓰는지는 18장의 "Using Trait Objects to Abstract over Shared Behavior" 절에서 다룰 거예요.
트레이트 바운드로 메서드를 조건부로 구현하기
제네릭 타입 파라미터를 쓰는 impl 블록에 트레이트 바운드를 붙이면, 지정한 트레이트를 구현하는 타입에 대해서만 메서드를 조건부로 구현할 수 있어요. 예를 들어 리스팅 10-15의 타입 Pair<T>는 항상 new 함수를 구현해서 Pair<T>의 새 인스턴스를 반환해요(5장 "Method Syntax" 절에서 Self가 impl 블록 타입, 여기서는 Pair<T>의 타입 별칭이라고 배웠죠). 그런데 다음 impl 블록에서 Pair<T>는 내부 타입 T가 비교를 가능하게 하는 PartialOrd 트레이트와 출력을 가능하게 하는 Display 트레이트를 구현할 때만 cmp_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)**이라고 부르는데, Rust 표준 라이브러리에서 아주 널리 쓰여요. 예를 들어 표준 라이브러리는 Display 트레이트를 구현하는 어떤 타입에든 ToString 트레이트를 구현해요. 그 표준 라이브러리의 impl 블록은 대략 이렇게 생겼어요:
impl<T: Display> ToString for T {
// --snip--
}
표준 라이브러리에 이 불평크 구현이 있기 때문에, Display 트레이트를 구현하는 어떤 타입에서든 ToString 트레이트가 정의한 to_string 메서드를 호출할 수 있어요. 예를 들어 정수는 Display를 구현하므로, 정수를 그에 대응하는 String 값으로 바꿀 수 있어요:
#![allow(unused)]
fn main() {
let s = 3.to_string();
}
불평크 구현은 해당 트레이트 문서의 "Implementors" 절에 나타나요.
트레이트와 트레이트 바운드를 쓰면 제네릭 타입 파라미터를 사용해서 중복을 줄이는 코드를 작성하면서도, 컴파일러에 "이 제네릭 타입이 특정 동작을 가져야 한다"고 지정할 수 있어요. 그러면 컴파일러는 트레이트 바운드 정보를 이용해 우리 코드에서 쓰는 모든 구체 타입이 올바른 동작을 제공하는지 확인해요. 동적 타입 언어에서는 메서드를 정의하지 않은 타입에 메서드를 호출하면 런타임에 에러가 나는데, Rust는 이 에러를 컴파일 타임으로 옮겨서 코드가 실행되기 전에 문제를 고치도록 강제해요. 게다가 런타임에 동작을 확인하는 코드를 쓸 필요도 없어요. 이미 컴파일 타임에 확인했으니까요. 이 덕분에 제네릭의 유연함을 포기하지 않으면서도 성능이 좋아져요.
더 알아보기
- 이어서 볼 내용: 제네릭 타입, 트레이트, 라이프타임 - 정리
- 13장에서 클로저와 이터레이터가 만들어내는 타입을 트레이트로 다루는 방법을 더 자세히 볼 수 있어요.
- 18장의 "Using Trait Objects to Abstract over Shared Behavior" 절에서 여러 타입을 반환하는 함수를 트레이트 객체로 다루는 방법을 알아봐요.