콘텐츠로 이동

Rust 자주 쓰는 트레이트

트레이트 페이지에서는 트레이트를 직접 만들어 봤어요. 그런데 표준 라이브러리에는 거의 모든 Rust 프로그램이 쓰는 트레이트가 이미 준비되어 있어요. 이 트레이트들은 타입에 관한 간단한 질문에 답해 줘요. 출력할 수 있나? 복사할 수 있나? 비교할 수 있나? 빈 값을 만들 수 있나?

유니폼에 붙은 배지를 떠올려 보세요. 배지 하나하나가 “이 타입은 X를 할 줄 안다”고 말해 줘요. 이런 배지 중 상당수는 #[derive(...)]로 공짜로 받을 수 있고, 나머지는 손으로 직접 써요. 이 페이지에서는 가장 자주 만나는 트레이트를 차례로 살펴보고, 나중에 다시 와서 볼 수 있는 표로 마무리할게요.

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는 {}가 사용하는 트레이트예요. 화면에서 사람이 읽는 메시지처럼 사용자에게 보여 줄 텍스트를 위한 거예요. 그게 어떤 모양이어야 할지 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 Herbert
That text has 21 characters
▶ 직접 실행해 보기

이 예제는 문자열을 영어로 그대로 두었어요. .len()은 글자 수가 아니라 바이트 수를 세는데, 한글은 한 글자가 3바이트라서 결과가 달라지기 때문이에요.

write!는 println!과 비슷하지만, 화면 대신 포매터 f에 써요. 덤으로 좋은 점도 있어요. 어떤 타입에 Display가 있으면 .to_string()이 공짜로 생겨요. Display는 사용자 정의 에러 페이지에서 다시 만나게 되는데, 거기서는 에러에 친절한 메시지를 붙여 주는 역할을 해요.

둘 다 값의 복제본을 만들지만, 방식이 달라요.

  • 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를 구현하면 ==와 !=를 쓸 수 있어요. 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: true
a != c: true
▶ 직접 실행해 보기

그럼 Eq는 뭘까요? Eq는 새 메서드를 추가하지 않아요. “모든 값은 언제나 자기 자신과 같다”는 약속이에요. 너무 당연해 보이지만, 부동소수점 수는 이 약속을 깨요. Rust에서 f64::NAN == f64::NAN은 false예요. 그래서 f64에는 PartialEq는 있지만 Eq는 없어요. 여러분의 타입을 HashMap의 키로 쓰거나 HashSet에 넣으려면 Eq(그리고 Hash)가 필요해요. 구조체에 부동소수점 필드가 없다면 둘 다 derive하세요.

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: true
1.2.0
1.10.3
2.0.0
▶ 직접 실행해 보기

필드를 순서대로 비교하기 때문에 필드를 쓰는 순서가 중요해요. 여기서는 major가 가장 중요한데, 버전 번호라면 딱 맞는 순서예요. 열거형은 먼저 나열한 배리언트가 “더 작은” 값이 돼요. Eq와 마찬가지로 부동소수점 수에는 PartialOrd만 있고 Ord는 없어요. 그래도 부동소수점 수를 정렬하는 방법은 정렬 페이지에서 볼 수 있어요.

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은 한 타입에서 다른 타입을 만드는 방법을 정의해요. 사실 이미 써 봤어요. 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 }))
▶ 직접 실행해 보기

사용자 정의 에러 페이지에서 이 아이디어를 더 자세히 발전시켜요.

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 expansion
2 | 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 expansion
2 | struct Product { name: String, price: f64 }
| ^^^^^^^^^^ the trait `Eq` is not implemented for `f64`

f64에는 Eq가 구현되어 있지 않다는 에러예요. PartialEq는 남기고 Eq는 빼세요. 아니면 가격을 u64로 된 정수 센트 단위로 저장하는 방법도 있는데, 돈을 다룰 때는 이쪽이 더 안전하기도 해요.

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);
}
}

실행 결과:

같은 할 일인가요? true
Task { 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.55
▶ 직접 실행해 보기
  • Debug({:?})는 개발자를 위한 것이고 거의 항상 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