Rust 자주 쓰는 트레이트
트레이트 페이지에서는 트레이트를 직접 만들어 봤어요. 그런데 표준 라이브러리에는 거의 모든 Rust 프로그램이 쓰는 트레이트가 이미 준비되어 있어요. 이 트레이트들은 타입에 관한 간단한 질문에 답해 줘요. 출력할 수 있나? 복사할 수 있나? 비교할 수 있나? 빈 값을 만들 수 있나?
유니폼에 붙은 배지를 떠올려 보세요. 배지 하나하나가 “이 타입은 X를 할 줄 안다”고 말해 줘요. 이런 배지 중 상당수는 #[derive(...)]로 공짜로 받을 수 있고, 나머지는 손으로 직접 써요. 이 페이지에서는 가장 자주 만나는 트레이트를 차례로 살펴보고, 나중에 다시 와서 볼 수 있는 표로 마무리할게요.
Debug: 개발자를 위한 출력
섹션 제목: “Debug: 개발자를 위한 출력”Debug를 구현하면 {:?}로 값을 출력할 수 있어요. 이건 개발자인 여러분을 위한 기능이에요. 코드를 만들고 고치는 동안 값을 확인할 때 쓰죠. 손으로 쓸 일은 거의 없고, 그냥 derive하면 돼요. {:#?}를 쓰면 필드를 한 줄에 하나씩 보여 주는 “보기 좋은” 형태로 출력되는데, 큰 구조체를 볼 때 훨씬 읽기 편해요.
#[derive(Debug)]struct Book { title: String, pages: u32,}
fn main() { let book = Book { title: String::from("듄"), pages: 412 }; println!("{:?}", book); println!("{:#?}", book);}실행 결과:
Book { title: "듄", pages: 412 }Book { title: "듄", pages: 412,}다른 코드에서 보게 될 dbg! 매크로도 Debug를 사용해요.
Display: 사용자를 위한 출력
섹션 제목: “Display: 사용자를 위한 출력”Display는 {}가 사용하는 트레이트예요. 화면에서 사람이 읽는 메시지처럼 사용자에게 보여 줄 텍스트를 위한 거예요. 그게 어떤 모양이어야 할지 Rust는 짐작할 수 없으니 derive는 없어요. fmt::Display를 구현해서 직접 써야 해요.
use std::fmt;
struct Book { title: String, author: String,}
impl fmt::Display for Book { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{} by {}", self.title, self.author) }}
fn main() { let book = Book { title: String::from("Dune"), author: String::from("Frank Herbert"), };
println!("{}", book);
let text = book.to_string(); println!("That text has {} characters", text.len());}실행 결과:
Dune by Frank HerbertThat text has 21 characters이 예제는 문자열을 영어로 그대로 두었어요. .len()은 글자 수가 아니라 바이트 수를 세는데, 한글은 한 글자가 3바이트라서 결과가 달라지기 때문이에요.
write!는 println!과 비슷하지만, 화면 대신 포매터 f에 써요. 덤으로 좋은 점도 있어요. 어떤 타입에 Display가 있으면 .to_string()이 공짜로 생겨요. Display는 사용자 정의 에러 페이지에서 다시 만나게 되는데, 거기서는 에러에 친절한 메시지를 붙여 주는 역할을 해요.
Clone과 Copy
섹션 제목: “Clone과 Copy”둘 다 값의 복제본을 만들지만, 방식이 달라요.
Clone:.clone()메서드를 줘요. 일부러 호출해야 하고,String의 텍스트를 전부 복사하는 것처럼 실제로 일을 할 수도 있어요.Copy: 값을 대입하거나 넘길 때마다 자동으로 복제본을 만들어요. 비트 단위로 그대로 복사할 수 있는 작고 단순한 타입에만 허용돼요. 숫자,bool,char, 그리고 이런 것들로만 이루어진 구조체요.
#[derive(Debug, Clone, Copy)]struct Point { x: i32, y: i32,}
#[derive(Debug, Clone)]struct User { name: String,}
fn main() { let p1 = Point { x: 1, y: 2 }; let p2 = p1; // Copy: p1을 계속 쓸 수 있어요 println!("{:?} 그리고 {:?}", p1, p2);
let u1 = User { name: String::from("Ana") }; let u2 = u1.clone(); // Clone: 복사해 달라고 직접 요청해요 println!("{:?} 그리고 {:?}", u1, u2);}실행 결과:
Point { x: 1, y: 2 } 그리고 Point { x: 1, y: 2 }User { name: "Ana" } 그리고 User { name: "Ana" }Copy가 없다면 let p2 = p1;은 p1을 이동(move)시키고, 그 뒤로는 p1을 쓸 수 없어요(소유권 참고). Copy에는 항상 Clone도 필요해요. 그래서 둘을 함께 derive하는 거예요.
PartialEq와 Eq: ==로 비교하기
섹션 제목: “PartialEq와 Eq: ==로 비교하기”PartialEq를 구현하면 ==와 !=를 쓸 수 있어요. derive하면 모든 필드가 같을 때 두 값이 같다고 판단해요.
#[derive(Debug, PartialEq)]struct Point { x: i32, y: i32,}
fn main() { let a = Point { x: 1, y: 2 }; let b = Point { x: 1, y: 2 }; let c = Point { x: 5, y: 0 };
println!("a == b: {}", a == b); println!("a != c: {}", a != c);}실행 결과:
a == b: truea != c: true그럼 Eq는 뭘까요? Eq는 새 메서드를 추가하지 않아요. “모든 값은 언제나 자기 자신과 같다”는 약속이에요. 너무 당연해 보이지만, 부동소수점 수는 이 약속을 깨요. Rust에서 f64::NAN == f64::NAN은 false예요. 그래서 f64에는 PartialEq는 있지만 Eq는 없어요. 여러분의 타입을 HashMap의 키로 쓰거나 HashSet에 넣으려면 Eq(그리고 Hash)가 필요해요. 구조체에 부동소수점 필드가 없다면 둘 다 derive하세요.
PartialOrd와 Ord: <와 >로 비교하기
섹션 제목: “PartialOrd와 Ord: <와 >로 비교하기”PartialOrd를 구현하면 <, >, <=, >=를 쓸 수 있어요. Ord는 완전한 순서를 더해 주는데, .sort(), .max(), .min()에 필요한 게 바로 이거예요. derive하면 Rust는 필드를 위에서 아래로 비교해요. 첫 번째 필드를 먼저 비교하고, 그 값이 같을 때만 다음 필드를 봐요.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]struct Version { major: u32, minor: u32, patch: u32,}
fn main() { let old = Version { major: 1, minor: 2, patch: 0 }; let new = Version { major: 1, minor: 10, patch: 3 }; println!("old < new: {}", old < new);
let mut versions = vec![ Version { major: 2, minor: 0, patch: 0 }, new, old, ]; versions.sort();
for v in &versions { println!("{}.{}.{}", v.major, v.minor, v.patch); }}실행 결과:
old < new: true1.2.01.10.32.0.0필드를 순서대로 비교하기 때문에 필드를 쓰는 순서가 중요해요. 여기서는 major가 가장 중요한데, 버전 번호라면 딱 맞는 순서예요. 열거형은 먼저 나열한 배리언트가 “더 작은” 값이 돼요. Eq와 마찬가지로 부동소수점 수에는 PartialOrd만 있고 Ord는 없어요. 그래도 부동소수점 수를 정렬하는 방법은 정렬 페이지에서 볼 수 있어요.
Default: 적당한 시작 값
섹션 제목: “Default: 적당한 시작 값”Default는 타입에 “시작” 값을 만들어 주는 ::default() 함수를 줘요. derive하면 각 필드가 자기 기본값을 가져요. 숫자는 0, bool은 false, 빈 String, 빈 Vec처럼요. ..Default::default() 문법을 쓰면 몇몇 필드만 정하고 나머지는 기본값으로 채울 수 있어요.
#[derive(Debug, Default)]struct Settings { volume: u32, muted: bool, username: String,}
fn main() { let basic = Settings::default(); println!("{:?}", basic);
let custom = Settings { volume: 80, ..Default::default() }; println!("{:?}", custom);}실행 결과:
Settings { volume: 0, muted: false, username: "" }Settings { volume: 80, muted: false, username: "" }다른 시작 값(예를 들어 볼륨 50)을 원한다면 derive하지 말고 impl Default for Settings를 직접 쓰세요. 원하는 값을 돌려주는 fn default() -> Self를 만들면 돼요.
From과 Into: 타입 사이의 변환
섹션 제목: “From과 Into: 타입 사이의 변환”From은 한 타입에서 다른 타입을 만드는 방법을 정의해요. 사실 이미 써 봤어요. String::from("hi")가 바로 그거예요. From을 구현하면 Rust가 반대 방향의 Into를 공짜로 줘요.
struct Celsius(f64);struct Fahrenheit(f64);
impl From<Celsius> for Fahrenheit { fn from(c: Celsius) -> Self { Fahrenheit(c.0 * 9.0 / 5.0 + 32.0) }}
fn main() { let boiling = Fahrenheit::from(Celsius(100.0)); println!("물은 {}°F에서 끓어요", boiling.0);
let body: Fahrenheit = Celsius(37.0).into(); println!("체온은 {:.1}°F예요", body.0);}실행 결과:
물은 212°F에서 끓어요체온은 98.6°F예요.into()를 쓸 때는 Rust가 목표 타입을 알아야 해요. 그래서 let body: Fahrenheit라고 타입을 적어 줬어요.
?가 From을 쓰는 방법. From이 진짜 빛나는 곳은 여기예요. ? 연산자는 에러를 만나면 From::from을 호출해서, 그 에러를 함수가 돌려주는 에러 타입으로 바꿔요. 그러니 여러분의 에러 타입에 From을 구현해 두면 ?가 알아서 에러를 변환해 줘요.
use std::num::ParseIntError;
#[derive(Debug)]enum AppError { BadNumber(ParseIntError),}
impl From<ParseIntError> for AppError { fn from(e: ParseIntError) -> Self { AppError::BadNumber(e) }}
fn parse_age(text: &str) -> Result<u32, AppError> { let age: u32 = text.parse()?; // 여기서 ParseIntError가 AppError로 바뀌어요 Ok(age)}
fn main() { println!("{:?}", parse_age("42")); println!("{:?}", parse_age("abc"));}실행 결과:
Ok(42)Err(BadNumber(ParseIntError { kind: InvalidDigit }))사용자 정의 에러 페이지에서 이 아이디어를 더 자세히 발전시켜요.
Add로 연산자 오버로딩하기
섹션 제목: “Add로 연산자 오버로딩하기”Rust에서 a + b는 사실 std::ops::Add 트레이트의 add 메서드를 호출하는 거예요. 여러분의 타입에 이 트레이트를 구현하면 +가 그냥 동작해요. type Output은 덧셈 결과가 어떤 타입인지 알려 줘요.
use std::ops::Add;
#[derive(Debug, Clone, Copy, PartialEq)]struct Point { x: i32, y: i32,}
impl Add for Point { type Output = Point;
fn add(self, other: Point) -> Point { Point { x: self.x + other.x, y: self.y + other.y } }}
fn main() { let a = Point { x: 1, y: 2 }; let b = Point { x: 3, y: 4 }; println!("{:?}", a + b);}실행 결과:
Point { x: 4, y: 6 }Sub(-), Mul(*), Neg(단항 -), AddAssign(+=)도 같은 방식으로 동작해요. 두 점을 더하거나 두 금액을 더하는 것처럼 의미가 뻔할 때 쓰세요. 여러분의 타입에서 +가 무슨 뜻인지 읽는 사람이 추측해야 한다면, 이름이 분명한 일반 메서드가 더 나아요.
어떤 트레이트를 derive해야 할까요?
섹션 제목: “어떤 트레이트를 derive해야 할까요?”일반적인 구조체를 위한 간단한 안내예요.
| 트레이트 | derive할까요? | 얻는 것 |
|---|---|---|
Debug |
거의 항상 | {:?}와 {:#?} 출력 |
Clone |
대체로 | .clone() |
Copy |
String, Vec, Box 필드가 없는 작은 타입만 |
이동 대신 자동 복사 |
PartialEq |
대체로 | ==와 != |
Eq |
부동소수점 필드가 없을 때 | HashMap 키에 필요(Hash와 함께) |
Hash |
HashMap 키로 쓰거나 HashSet에 넣을 때 |
해싱 |
PartialOrd, Ord |
값에 자연스러운 순서가 있을 때 | <, >, .sort(), .max() |
Default |
“빈” 값이 말이 될 때 | ::default()와 ..Default::default() |
Display |
derive할 수 없어요. 직접 써야 해요 | {} 출력과 .to_string() |
아주 흔한 출발점은 #[derive(Debug, Clone, PartialEq)]예요. 필요할 때 더 추가하세요. 빠진 게 있으면 컴파일러가 알려 줄 거예요.
자주 하는 실수
섹션 제목: “자주 하는 실수”String이 있는 구조체에 Copy를 derive하기.
#[derive(Clone, Copy)]struct User { name: String, age: u32,}error[E0204]: the trait `Copy` cannot be implemented for this type --> src/main.rs:2:8 |1 | #[derive(Clone, Copy)] | ---- in this derive macro expansion2 | struct User { | ^^^^3 | name: String, | ------------ this field does not implement `Copy`“이 타입에는 Copy 트레이트를 구현할 수 없다”, “이 필드는 Copy를 구현하지 않는다”는 뜻이에요. String은 힙 메모리를 소유하고 있어서 비트 단위로 복사할 수 없어요. Copy를 빼고, 복제본이 필요할 때는 .clone()을 쓰세요.
부동소수점 수가 있는 구조체에 Eq를 derive하기.
#[derive(Debug, PartialEq, Eq)]struct Product { name: String, price: f64 }error[E0277]: the trait bound `f64: Eq` is not satisfied --> src/main.rs:2:32 |1 | #[derive(Debug, PartialEq, Eq)] | -- in this derive macro expansion2 | struct Product { name: String, price: f64 } | ^^^^^^^^^^ the trait `Eq` is not implemented for `f64`f64에는 Eq가 구현되어 있지 않다는 에러예요. PartialEq는 남기고 Eq는 빼세요. 아니면 가격을 u64로 된 정수 센트 단위로 저장하는 방법도 있는데, 돈을 다룰 때는 이쪽이 더 안전하기도 해요.
연습 문제
섹션 제목: “연습 문제”1. 할 일 목록 정렬하기
섹션 제목: “1. 할 일 목록 정렬하기”priority: u8와 title: String을 가진 Task 구조체를 만들어 보세요. Vec<Task>를 정렬하고, 각 할 일을 {:?}로 출력하고, 두 할 일을 ==로 비교할 수 있도록 필요한 트레이트를 derive하세요. 숫자가 작을수록 급한 일이니 앞에 와야 해요. 힌트: .sort()에는 Ord가 필요하고, Ord에는 Eq, PartialEq, PartialOrd도 필요해요. priority를 먼저 비교하도록 맨 앞에 두세요.
정답 보기
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]struct Task { priority: u8, title: String,}
fn main() { let mut tasks = vec![ Task { priority: 3, title: String::from("화분에 물 주기") }, Task { priority: 1, title: String::from("월세 내기") }, Task { priority: 2, title: String::from("Sam에게 답장하기") }, ];
let copy = tasks[1].clone(); println!("같은 할 일인가요? {}", copy == tasks[1]);
tasks.sort(); for task in &tasks { println!("{:?}", task); }}실행 결과:
같은 할 일인가요? trueTask { priority: 1, title: "월세 내기" }Task { priority: 2, title: "Sam에게 답장하기" }Task { priority: 3, title: "화분에 물 주기" }2. 출력도 되고 더할 수도 있는 돈
섹션 제목: “2. 출력도 되고 더할 수도 있는 돈”정수 cents: u64를 저장하는 Money 구조체를 만들어 보세요. 350.into()가 동작하도록 From<u64>를, $3.50처럼 출력되도록 Display를, +로 두 금액을 더할 수 있도록 Add를 구현하세요. 힌트: 달러는 cents / 100이고 나머지는 cents % 100이에요. 나머지를 {:02}로 출력하면 5센트가 05로 보여요. 더한 다음에도 금액을 출력할 수 있도록 Copy를 derive하세요.
정답 보기
use std::fmt;use std::ops::Add;
#[derive(Debug, Clone, Copy, PartialEq)]struct Money { cents: u64,}
impl From<u64> for Money { fn from(cents: u64) -> Self { Money { cents } }}
impl fmt::Display for Money { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "${}.{:02}", self.cents / 100, self.cents % 100) }}
impl Add for Money { type Output = Money;
fn add(self, other: Money) -> Money { Money { cents: self.cents + other.cents } }}
fn main() { let coffee: Money = 350.into(); let cake = Money::from(405); let total = coffee + cake;
println!("커피: {coffee}"); println!("케이크: {cake}"); println!("합계: {total}");}실행 결과:
커피: $3.50케이크: $4.05합계: $7.55Debug({:?})는 개발자를 위한 것이고 거의 항상 derive해요.Display({})는 사용자를 위한 것이고 손으로 직접 써요.Clone은.clone()으로 요청할 때 복사하고,Copy는 자동으로 복사하지만 단순한 타입에만 쓸 수 있어요.PartialEq/Eq는==를,PartialOrd/Ord는<와 정렬을 줘요. 필드는 위에서 아래로 비교해요.Default는 시작 값을 만들어요...Default::default()는 정하지 않은 필드를 채워 줘요.From을 구현하면Into가 공짜로 생겨요.?연산자는From으로 에러를 변환해요.std::ops::Add(와 그 친구들)를 구현하면 여러분의 타입에도+를 쓸 수 있어요.
더 알고 싶다면 고급 트레이트 페이지에서 type Output 같은 연관 타입, 기본 타입 매개변수, 남이 만든 타입에 트레이트를 구현하는 방법을 설명해요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. println!의 {}는 어떤 트레이트를 사용할까요?
2. String 필드가 있는 구조체는 왜 Copy를 derive할 수 없을까요?
3. PartialOrd를 derive하면 두 구조체를 어떻게 비교할까요?
4. From<ParseIntError> for AppError를 구현했어요. 그러면 ?는 무엇을 할 수 있을까요?
5. 여러분의 타입에서 a + b가 동작하게 하려면 어떤 트레이트를 구현해야 할까요?
점수: 0 / 5
더 알아보기
섹션 제목: “더 알아보기”- C - 파생 가능한 트레이트 — The Rust Programming Language 한국어판:
#[derive]할 수 있는 모든 트레이트와, derive한 버전이 무엇을 하는지, 언제 쓰면 좋은지 알려 줘요. std::fmt— 포매팅과 Display (영어): 너비, 채우기,{:#?}같은 모든 포매팅 옵션과Display,Debug의 관계를 설명해요.FromandInto— Rust by Example (영어): 직접 만든 타입끼리 변환하는 짧은 예제를 실행해 볼 수 있어요.std::ops— 연산자 트레이트 (영어):Add부터Index까지, 오버로딩할 수 있는 연산자의 전체 목록이에요.std::cmp— 비교 트레이트 (영어):PartialEq,Eq,PartialOrd,Ord를 자세히, 각각이 약속하는 규칙과 함께 설명해요.