트레이트와 제네릭 - 공통 동작 약속하기
이 장에서 배우는 것
앞 장에서 Vec, String, HashMap에 가계부 항목을 담아 보았다. 이 장에서는 항목의 종류가 늘어날 때 코드가 복사되지 않도록, 서로 다른 타입이 같은 동작을 하겠다고 약속하는 방법을 배운다. Rust에서 이 약속을 트레이트(trait)라고 부르고, 하나의 함수를 여러 타입에 쓰게 해 주는 문법을 제네릭(generic)이라고 부른다.
- 트레이트를 정의하고 타입에 구현할 수 있다.
- 제네릭 함수에 트레이트 경계(trait bound)를 붙여 "이 동작을 하는 타입만 받는다"고 쓸 수 있다.
#[derive(Debug, Clone, PartialEq)]가 만들어 주는 것과 그 조건을 설명할 수 있다.Display를 직접 구현해{}로 출력할 수 있다.- 인자와 반환값 자리에서
impl Trait를 쓸 수 있다.
문제 상황
가계부 도구에 지출(Expense)만 있을 때는 문제가 없었다. 그런데 수입(Income)을 기록하기로 하자 곤란해진다. 두 타입 모두 "화면에 한 줄로 보여 주기"와 "합계 내기"가 필요하다. 이대로 두면 expense_summary, income_summary, total_expenses, total_incomes 같은 함수가 쌍으로 늘어난다. 나중에 저축 항목이 추가되면 세 벌이 된다.
또 다른 불편도 있다. 디버깅하려고 println!("{:?}", expense)를 썼더니 컴파일이 되지 않는다. 항목을 복제하거나 두 항목이 같은지 비교하는 코드도 직접 써야 하는지 궁금해진다. 이 장은 이 두 가지를 함께 푼다. 공통 동작은 트레이트로 한 번만 쓰고, 흔한 동작은 derive로 자동 생성한다.
트레이트: 동작을 약속으로 적기
정의와 구현
트레이트는 "이 이름의 메서드들을 가진 타입이다"라는 약속이다. 다른 언어의 인터페이스와 비슷하다. 가계부 항목이 지켜야 할 약속을 이렇게 적는다.
trait Entry {
fn title(&self) -> String;
fn amount(&self) -> i64;
fn summary(&self) -> String {
format!("{} {:+}원", self.title(), self.amount())
}
}
title과 amount는 본문 없이 시그니처(이름, 매개변수, 반환 타입)만 적었다. 구현하는 타입이 채워야 하는 부분이다. summary는 본문이 있다. 이를 기본 구현(default method)이라고 하며, 구현하는 쪽이 따로 쓰지 않으면 이 본문이 그대로 쓰인다. 기본 구현은 같은 트레이트의 다른 메서드를 호출할 수 있다. 여기서는 title과 amount를 불러 쓴다.
타입에 약속을 지키게 하는 것은 impl 트레이트 for 타입 블록이다.
impl Entry for Income {
fn title(&self) -> String {
format!("[수입] {}", self.source)
}
fn amount(&self) -> i64 {
self.won
}
}
이 블록에는 기본 구현이 없는 메서드를 모두 써야 한다. 하나라도 빠지면 컴파일되지 않는다. 이 장의 실수 목록에서 다시 본다.
구현 위치에 관한 규칙이 하나 있다. 트레이트나 타입 중 적어도 하나는 내 크레이트(패키지 단위의 컴파일 묶음)에 있어야 한다. 내가 만든 Entry를 i32 같은 표준 타입에 구현하는 것은 되고, 표준 라이브러리의 Display를 내 Expense에 구현하는 것도 된다. 표준 트레이트를 표준 타입에 구현하는 것은 되지 않는다. 이 제약을 고아 규칙(orphan rule)이라고 부른다.
제네릭과 트레이트 경계
제네릭은 타입을 매개변수처럼 받는 문법이다. 꺾쇠 안에 타입 이름 자리표시자를 적는다. 합계 함수를 제네릭으로 쓰면 한 벌로 끝난다.
fn total<T: Entry>(items: &[T]) -> i64 {
let mut sum = 0;
for item in items {
sum += item.amount();
}
sum
}
<T: Entry>에서 T는 어떤 타입이든 될 수 있는 자리표시자이고, : Entry가 트레이트 경계다. "T는 Entry를 구현한 타입이어야 한다"는 뜻이다. 경계가 있으므로 함수 안에서 item.amount()를 호출할 수 있다. 경계가 없으면 T가 무엇인지 알 수 없어서 컴파일러가 메서드 호출을 허용하지 않는다.
컴파일러는 호출하는 곳마다 T가 실제로 어떤 타입인지 확인하고 경계를 검사한다. 구현이 없는 타입을 넣으면 함수 본문이 아니라 호출 지점에서 오류가 난다.
경계가 길어지면 where 절로 뺄 수 있다. 여러 트레이트를 요구할 때는 +로 잇는다.
fn print_all<T>(items: &[T])
where
T: Entry + fmt::Display,
{
for item in items {
println!(" {} -> {}", item, item.summary());
}
}
슬라이스 &[T]의 T는 한 번에 한 종류다. Expense와 Income을 한 슬라이스에 섞어 담는 것은 이 방식으로는 되지 않는다. 섞어 담는 방법(트레이트 객체)은 따로 있지만 이 장의 범위를 벗어나므로 다루지 않는다.
derive, Display, impl Trait
derive 로 자동 구현하기
타입 정의 위에 #[derive(...)]를 쓰면 컴파일러가 표준 트레이트의 구현을 대신 만들어 준다. 이 장에서 쓰는 세 가지는 다음과 같다.
| 트레이트 | 가능해지는 것 | 사용 예 | 조건 |
|---|---|---|---|
Debug | 개발자용 출력 | {:?} | 모든 필드가 Debug |
Clone | 값을 복제 | x.clone() | 모든 필드가 Clone |
PartialEq | 같은지 비교 | a == b | 모든 필드가 PartialEq |
세 트레이트 모두 "모든 필드가 그 트레이트를 구현해야 한다"는 조건이 붙는다. Expense가 String, Category, i64를 가지므로 Category에도 같은 derive가 필요하다. Clone은 앞 장에서 본 소유권과 이어진다. clone()은 힙 데이터까지 새로 복사해 독립된 주인을 만든다. 복제본을 고쳐도 원본은 바뀌지 않는다.
PartialEq의 "Partial"은 모든 값이 자기 자신과 같다고는 보장하지 않는다는 뜻이다. 부동소수점의 NaN이 그런 예다. 정수와 문자열만 담은 타입에서는 신경 쓰지 않아도 된다.
Display 직접 구현하기
Debug는 개발자가 보는 출력이고, 사용자에게 보여 줄 출력은 Display가 맡는다. Display는 derive로 만들 수 없다. 어떻게 보일지는 타입마다 다르기 때문이다. 직접 구현한다.
impl fmt::Display for Category {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let name = match self {
Category::Food => "식비",
Category::Transport => "교통",
Category::Housing => "주거",
};
write!(f, "{}", name)
}
}
fmt는 출력 대상 f에 글자를 써 넣고 성공 여부(fmt::Result)를 돌려준다. write! 매크로는 println!과 같은 서식 문법으로 f에 쓴다. &mut fmt::Formatter에 생략된 수명 표기는 다음 장에서 다룬다. 지금은 이 형태 그대로 쓰면 된다.
덤이 하나 있다. Display를 구현한 타입은 to_string() 메서드도 얻는다. 표준 라이브러리가 "Display를 구현한 모든 타입에 ToString을 구현한다"고 미리 써 두었기 때문이다. 이 장의 완성 코드에서 분류 이름을 String으로 바꿀 때 쓴다.
impl Trait 로 간단히 쓰기
경계만 필요한 함수는 <T: ...>를 쓰지 않고 impl Trait로 줄여 쓸 수 있다.
fn show(entry: &impl Entry) {
println!("{}", entry.summary());
}
fn monthly_salary() -> impl Entry {
Income { source: String::from("9월 급여"), won: 3_000_000 }
}
인자 자리의 &impl Entry는 <T: Entry>(entry: &T)와 같은 뜻의 짧은 표기다. 반환 자리의 impl Entry는 "Entry를 구현한 어떤 타입 하나를 돌려준다"는 뜻이다. 호출하는 쪽은 구체적인 타입 이름을 모른 채 Entry의 메서드만 쓸 수 있다. 다만 함수는 한 가지 타입만 돌려줘야 한다. if의 갈래에 따라 Income과 Expense를 번갈아 돌려주는 것은 허용되지 않는다.
| 표기 | 쓰는 자리 | 같은 타입을 여러 인자에 강제 | 특징 |
|---|---|---|---|
<T: Entry> | 인자, 반환 | 가능(a: T, b: T) | 타입 이름 T를 본문에서 쓸 수 있다 |
where T: Entry + Display | 인자, 반환 | 가능 | 경계가 길 때 읽기 쉽다 |
impl Entry (인자) | 인자 | 불가(각각 다른 타입 가능) | 짧다 |
impl Entry (반환) | 반환 | 해당 없음 | 구체 타입을 숨긴다. 한 종류만 반환 |
완성 코드
cargo new ledger로 만든 프로젝트의 src/main.rs를 아래 내용으로 바꾼다.
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
enum Category {
Food,
Transport,
Housing,
}
impl fmt::Display for Category {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let name = match self {
Category::Food => "식비",
Category::Transport => "교통",
Category::Housing => "주거",
};
write!(f, "{}", name)
}
}
#[derive(Debug, Clone, PartialEq)]
struct Expense {
memo: String,
category: Category,
won: i64,
}
#[derive(Debug, Clone, PartialEq)]
struct Income {
source: String,
won: i64,
}
impl fmt::Display for Expense {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{} ({}) {}원", self.memo, self.category, self.won)
}
}
trait Entry {
fn title(&self) -> String;
fn amount(&self) -> i64;
fn summary(&self) -> String {
format!("{} {:+}원", self.title(), self.amount())
}
}
impl Entry for Expense {
fn title(&self) -> String {
format!("[{}] {}", self.category, self.memo)
}
fn amount(&self) -> i64 {
-self.won
}
}
impl Entry for Income {
fn title(&self) -> String {
format!("[수입] {}", self.source)
}
fn amount(&self) -> i64 {
self.won
}
}
fn total<T: Entry>(items: &[T]) -> i64 {
let mut sum = 0;
for item in items {
sum += item.amount();
}
sum
}
fn print_all<T>(items: &[T])
where
T: Entry + fmt::Display,
{
for item in items {
println!(" {} -> {}", item, item.summary());
}
}
fn show(entry: &impl Entry) {
println!("{}", entry.summary());
}
fn monthly_salary() -> impl Entry {
Income {
source: String::from("9월 급여"),
won: 3_000_000,
}
}
fn totals_by_category(items: &[Expense]) -> HashMap<String, i64> {
let mut map = HashMap::new();
for item in items {
*map.entry(item.category.to_string()).or_insert(0) += item.won;
}
map
}
fn main() {
let expenses = vec![
Expense { memo: String::from("점심"), category: Category::Food, won: 9_000 },
Expense { memo: String::from("지하철"), category: Category::Transport, won: 1_500 },
Expense { memo: String::from("저녁"), category: Category::Food, won: 12_000 },
Expense { memo: String::from("월세"), category: Category::Housing, won: 400_000 },
];
let incomes = vec![Income {
source: String::from("9월 급여"),
won: 3_000_000,
}];
println!("== 지출 목록 ==");
print_all(&expenses);
println!("== 디버그 출력 ==");
println!("{:?}", expenses[0]);
let mut copy = expenses[0].clone();
println!("복제 직후 같은가: {}", copy == expenses[0]);
copy.won = 9_500;
println!("금액 수정 후 같은가: {}", copy == expenses[0]);
println!("== 합계 ==");
let salary = monthly_salary();
show(&salary);
println!("수입 합계: {}원", total(&incomes));
println!("지출 합계: {}원", total(&expenses));
println!("남은 돈: {}원", total(&incomes) + total(&expenses));
println!("== 분류별 지출 ==");
let totals = totals_by_category(&expenses);
let mut names: Vec<&String> = Vec::new();
for name in totals.keys() {
names.push(name);
}
names.sort();
for name in names {
println!("{} {}원", name, totals[name]);
}
}
줄별 해설
use std::fmt;:Display,Formatter,Result가 들어 있는 모듈을 가져온다. 코드에서는fmt::Display처럼 모듈 이름을 붙여 쓴다.#[derive(Debug, Clone, PartialEq)]를Category,Expense,Income세 곳에 붙였다.Expense가Category를 필드로 가지므로Category에도 세 트레이트가 있어야 한다.impl fmt::Display for Category:match self로 분류마다 한글 이름을 고르고write!로 출력 대상에 쓴다. 이 구현 덕분에format!("[{}] ...", self.category)와to_string()이 동작한다.impl fmt::Display for Expense: 필드 세 개를 "점심 (식비) 9000원" 꼴로 이어 쓴다. 안쪽{}에서Category의Display가 다시 호출된다.trait Entry:title과amount는 구현하는 타입이 채우고,summary는 기본 구현을 쓴다.{:+}는 양수에도+부호를 붙이는 서식이다.impl Entry for Expense: 지출은 금액을 음수로 돌려준다(-self.won). 수입은 그대로 돌려준다. 이렇게 부호를 통일해 두면 합계 함수가 두 타입을 구분할 필요가 없다.fn total<T: Entry>:&[T]는T값들의 슬라이스를 빌려 받는다는 뜻이다. 소유권은 넘어가지 않으므로 호출한 뒤에도expenses를 계속 쓴다.sum의 타입은 반환 타입에서i64로 정해진다.fn print_all:where절이Entry와Display를 모두 요구한다. 그래서 본문에서{}와summary()를 함께 쓸 수 있다.Income에는Display가 없으므로print_all(&incomes)는 컴파일되지 않는다.fn show(entry: &impl Entry)와fn monthly_salary() -> impl Entry: 호출부에서salary의 실제 타입 이름은 드러나지 않고Entry의 메서드만 쓰인다.totals_by_category: 분류를to_string()으로String으로 바꿔 키로 쓰고,entry(...).or_insert(0)으로 없으면 0을 넣은 뒤 금액을 더한다. 앞 장의 패턴 그대로다.main의let mut copy = expenses[0].clone();: 독립된 복제본을 만든다.copy.won을 고친 뒤==가false가 되는 것으로 두 값이 따로 존재함을 확인한다.==는 derive된PartialEq가 필드를 하나씩 비교한다.- 마지막 반복:
HashMap은 순회 순서가 정해져 있지 않으므로 키를Vec에 모아sort()한 뒤 그 순서로 출력한다.totals[name]은 키로 값을 찾는 표기다.
실행 결과
$ cargo run
== 지출 목록 ==
점심 (식비) 9000원 -> [식비] 점심 -9000원
지하철 (교통) 1500원 -> [교통] 지하철 -1500원
저녁 (식비) 12000원 -> [식비] 저녁 -12000원
월세 (주거) 400000원 -> [주거] 월세 -400000원
== 디버그 출력 ==
Expense { memo: "점심", category: Food, won: 9000 }
복제 직후 같은가: true
금액 수정 후 같은가: false
== 합계 ==
[수입] 9월 급여 +3000000원
수입 합계: 3000000원
지출 합계: -422500원
남은 돈: 2577500원
== 분류별 지출 ==
교통 1500원
식비 21000원
주거 400000원
Debug 출력에서 category가 식비가 아니라 Food로 나온 점을 눈여겨보자. {:?}는 derive로 만든 Debug를, {}는 직접 쓴 Display를 쓰기 때문이다.
실무에서 자주 틀리는 것
1. 트레이트 경계 없이 메서드를 부른다
fn total<T>(items: &[T]) -> i64 {
let mut sum = 0;
for item in items {
sum += item.amount();
}
sum
}
오류 요지: error[E0599]: no method named `amount` found for reference `&T` in the current scope. 컴파일러는 T가 무엇인지 모르므로 어떤 메서드가 있는지도 알 수 없다. 도움말이 "타입 매개변수 T에 Entry 경계를 붙이라"고 안내한다. 고친 코드는 fn total<T: Entry>(items: &[T]) -> i64다.
2. derive 없이 == 를 쓴다
struct Expense {
memo: String,
category: Category,
won: i64,
}
fn main() {
let a = Expense { memo: String::from("점심"), category: Category::Food, won: 9_000 };
let b = a.clone();
println!("{}", a == b);
}
오류 요지: error[E0369]: binary operation `==` cannot be applied to type `Expense`. clone()도 마찬가지로 Clone이 없어서 실패한다. 고친 코드는 struct 위에 #[derive(Clone, PartialEq)]를 붙이는 것이다. 이때 Category에도 같은 derive가 있어야 한다.
3. Debug 만 있는 타입을 {} 로 출력한다
#[derive(Debug)]
struct Income {
source: String,
won: i64,
}
fn main() {
let pay = Income { source: String::from("급여"), won: 3_000_000 };
println!("{}", pay);
}
오류 요지: error[E0277]: `Income` doesn't implement `std::fmt::Display`. {}는 Display를, {:?}는 Debug를 요구한다. 둘은 별개의 트레이트다. 사용자에게 보여 줄 출력이라면 impl fmt::Display for Income을 쓰고, 개발 중 확인용이라면 {:?}로 바꾼다.
4. 트레이트의 필수 메서드를 빠뜨린다
impl Entry for Income {
fn title(&self) -> String {
format!("[수입] {}", self.source)
}
}
오류 요지: error[E0046]: not all trait items implemented, missing: `amount`. 기본 구현이 없는 메서드는 모두 써야 한다. fn amount(&self) -> i64 { self.won }을 추가하면 컴파일된다. 기본 구현이 있는 summary는 빠뜨려도 된다.
한눈에 보기
| 문법 | 의미 | 이 장의 예 | 주의 |
|---|---|---|---|
trait Name { ... } | 동작의 약속을 정의 | trait Entry | 본문 없는 메서드는 구현 필수 |
impl Trait for Type | 타입이 약속을 지킨다 | impl Entry for Income | 고아 규칙 |
| 기본 구현 | 구현을 생략해도 쓰이는 본문 | summary | 다른 메서드를 호출할 수 있다 |
<T: Trait> | 트레이트 경계 | total<T: Entry> | 없으면 메서드 호출 불가 |
where T: A + B | 여러 경계를 뒤로 뺀다 | print_all | 경계는 +로 잇는다 |
#[derive(...)] | 표준 트레이트 자동 구현 | Debug, Clone, PartialEq | 모든 필드가 같은 트레이트를 구현해야 한다 |
impl fmt::Display | {} 출력과 to_string() | Category, Expense | derive 불가, 직접 구현 |
impl Trait | 인자·반환의 짧은 표기 | monthly_salary | 반환은 한 종류 타입만 |
연습 문제
Income에Display를 구현해println!("{}", incomes[0])이9월 급여: 3000000원을 출력하게 하라.Entry에 기본 구현 메서드is_expense(&self) -> bool을 추가하라. 금액이 0보다 작으면true를 돌려준다.Expense와Income은 코드를 고치지 않아야 한다.- 슬라이스에서 금액의 절댓값이 가장 큰 항목의 절댓값을 돌려주는 제네릭 함수
biggest를 써라. 빈 슬라이스면 0을 돌려준다. 완성 코드의 지출 목록에 쓰면 무엇이 출력되는가? total(&[1, 2, 3])은 왜 컴파일되지 않는가? 오류 코드와 이유를 설명하고, 통과시키는 방법을 하나 제시하라.
정답과 해설
1. Display를 직접 구현한다.
impl fmt::Display for Income {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}: {}원", self.source, self.won)
}
}
이 구현을 추가하면 print_all(&incomes)도 컴파일된다. Entry + Display 경계를 둘 다 만족하기 때문이다.
2. 트레이트 본문에 기본 구현을 넣는다.
fn is_expense(&self) -> bool {
self.amount() < 0
}
기본 구현은 이미 구현된 amount를 이용하므로 두 impl 블록은 그대로 둔다. 기본 구현 메서드는 그 트레이트를 구현한 모든 타입이 자동으로 물려받는다. 호출해서 쓰지 않으면 사용되지 않는 메서드라는 경고가 나올 수 있으니, 연습할 때는 main에서 한 번 호출해 본다.
3.
fn biggest<T: Entry>(items: &[T]) -> i64 {
let mut max = 0;
for item in items {
let value = item.amount().abs();
if value > max {
max = value;
}
}
max
}
지출 목록에 쓰면 400000이 나온다. abs()는 정수의 절댓값을 돌려주는 메서드다. 빈 슬라이스면 반복이 돌지 않으므로 초깃값 0이 그대로 반환된다.
4. 정수 리터럴의 기본 타입 i32가 Entry를 구현하지 않아서 error[E0277]: the trait bound `{integer}: Entry` is not satisfied 꼴의 오류가 난다. 컴파일러는 호출 지점에서 T를 정하고 경계를 검사한다. 통과시키려면 Entry가 내 트레이트이므로 impl Entry for i32를 쓰면 된다. 다만 title을 무엇으로 할지 같은 의미 설계가 필요하다. 가계부 의미를 가진 타입(예: 금액만 감싼 구조체)을 새로 만드는 쪽이 대체로 더 명확하다.
공식 문서에서 트레이트와 derive 목록을 확인하려면 The Rust Programming Language의 트레이트 절과 std::fmt::Display 문서를 참고한다. 다음 장에서는 이 장에서 넘어간 수명 표기를 다루며, 참조가 얼마나 오래 유효한지 컴파일러에 알려 주는 방법을 배운다.