Rust 기초 강좌 #8 트레이트와 제네릭: 상속 없이 공통 동작을 정의하는 법
이 강좌에는 갚지 않은 약속이 몇 개 쌓여 있습니다. 3편의 “Copy 트레이트를 구현한 타입”, 5편의 #[derive(Debug)], 6편의 Box<dyn Error>. 전부 이번 편의 주제인 트레이트(trait)입니다. 상속이 없는 Rust에서 “서로 다른 타입이 같은 동작을 한다"를 표현하는 유일한 장치이고, 제네릭과 결합해 Rust 추상화의 뼈대를 이룹니다.
트레이트: 동작의 약속 #
트레이트는 “이 동작을 제공한다"는 약속의 목록입니다. 인터페이스와 비슷하지만, 이미 존재하는 타입에 나중에 구현을 붙일 수 있다는 점이 다릅니다.
trait Summary {
fn summarize(&self) -> String;
}
struct Article { title: String, body: String }
struct Comment { author: String, text: String }
impl Summary for Article {
fn summarize(&self) -> String {
format!("[기사] {}", self.title)
}
}
impl Summary for Comment {
fn summarize(&self) -> String {
format!("{}님의 댓글: {}", self.author, self.text)
}
}기사와 댓글은 아무 관계도 없는 타입이지만, 둘 다 Summary를 구현했으므로 “요약할 수 있는 것"으로 함께 다룰 수 있습니다. 트레이트 정의에 본문을 써 두면 기본 구현이 되어, 구현하는 타입은 필요한 것만 재정의하면 됩니다.
제네릭과 트레이트 바운드: 아무 타입이나, 단 조건부로 #
“요약할 수 있는 것이면 무엇이든 받는 함수"를 쓰려면 제네릭이 필요합니다.
fn print_summary<T: Summary>(item: &T) {
println!("{}", item.summarize());
}<T: Summary>가 트레이트 바운드입니다. “T는 아무 타입이나 되지만, Summary를 구현해야 한다"는 조건입니다. 이 조건이 없으면 어떻게 될까요? 바운드를 지우면 컴파일러가 이렇게 답합니다.
error[E0599]: no method named `summarize` found for reference `&T`
|
= help: items from traits can only be used if the type parameter
is bounded by the trait다른 언어의 제네릭(또는 동적 타입)에서는 “그 메서드가 있는지"를 런타임에 가서야 알게 되는 경우가 있지만, Rust는 제네릭 함수가 사용하는 모든 능력을 바운드로 선언하게 합니다. 함수 시그니처가 곧 요구 조건의 문서가 됩니다. 조건이 여러 개면 T: Summary + Clone처럼 더합니다.
derive: 표준 트레이트의 자동 구현 #
5편의 #[derive(Debug)]는 “Debug 트레이트 구현을 자동 생성하라"는 지시였습니다. 자주 쓰는 파생 목록은 사실상 구조체 선언의 상비약입니다.
#[derive(Debug, Clone, PartialEq)]
struct Point { x: i32, y: i32 }- Debug:
{:?}출력. 디버깅의 기본 - Clone:
.clone()제공. 3편의 명시적 깊은 복사가 이것이었습니다 - PartialEq:
==비교. 파생하지 않은 구조체는==조차 컴파일되지 않습니다 - Copy: 3편의 “move 대신 복사되는 타입"의 정체. 모든 필드가
Copy일 때만 파생할 수 있어서,String을 담은 구조체에 붙이면 컴파일 에러가 납니다
연산자와 출력 같은 언어 기능이 전부 트레이트로 정의되어 있다는 것이 Rust의 일관된 구조입니다. println!의 {}는 Display 트레이트, + 연산자는 Add 트레이트, 7편의 for 순회는 Iterator 트레이트입니다. “이 타입에 이 문법을 쓸 수 있는가"라는 질문이 전부 “이 트레이트를 구현했는가"로 환원됩니다.
비용 이야기: 단형화와 dyn #
제네릭이 편리하면 성능이 궁금해질 차례입니다. Rust의 제네릭 함수는 단형화(monomorphization) 됩니다. 컴파일러가 실제로 쓰인 타입마다 전용 버전을 만들어 내는 방식입니다. print_summary가 Article과 Comment로 호출되면, 컴파일 결과에는 각각을 위한 함수 두 개가 들어가고 호출은 일반 함수 호출과 같아집니다. 제네릭을 써도 런타임 비용이 없다는 뜻이고, 7편에서 이터레이터 체인이 손으로 쓴 반복문과 대등하다고 한 것도 같은 원리입니다.
반대로 “어떤 타입인지 런타임에야 정해지는” 경우가 있습니다. 6편의 Box<dyn Error>가 그것입니다.
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Article { /* ... */ }),
Box::new(Comment { /* ... */ }),
];
for item in &items {
println!("{}", item.summarize());
}dyn Summary는 “Summary를 구현한 무언가"라는 뜻으로, 기사와 댓글을 한 Vec에 섞어 담을 수 있게 합니다. 대신 어느 summarize를 부를지 런타임에 찾는 동적 디스패치 비용이 생깁니다. 기본은 제네릭(컴파일 타임 확정)을 쓰고, 서로 다른 타입을 한 컬렉션에 섞어야 할 때만 dyn을 쓴다는 것이 실무 기준입니다.
정리 #
- 트레이트는 동작의 약속입니다. 관계없는 타입들에 같은 능력을 부여하고, 기존 타입에도 나중에 구현을 붙일 수 있습니다.
- 제네릭 함수는 사용하는 능력을 트레이트 바운드(
<T: Summary>)로 전부 선언해야 합니다. 시그니처가 요구 조건의 문서가 됩니다. derive(Debug, Clone, PartialEq)는 구조체의 상비약이고,Copy는 모든 필드가 Copy일 때만 가능합니다. 3편과 5편의 약속이 여기서 회수됩니다.{}출력은 Display,+는 Add, for는 Iterator라는 식으로, 언어 기능 대부분이 트레이트 구현 여부로 환원됩니다.- 제네릭은 단형화되어 런타임 비용이 없습니다. 타입이 런타임에 정해지는
dyn은 컬렉션에 섞어 담을 때만 쓰는 것이 기준입니다. - 마지막 편에서는 4편이 예고한 수명(lifetime)의 실체를 확인하고, 모듈과 외부 크레이트로 프로젝트를 구성하는 법까지 강좌를 마무리하겠습니다.