직접 Ticket의 PartialEq를 짜는 것은 지루한 과정이었다.
그렇지 않나?
게다가 구현은 또 빡세다..
만약, struct의 정의가 바뀌면(새로운 field가 추가된다던지) PartialEq 구현을 수정해야한다.
하지만 아래와 같이 destructuring을 통해 구조체의 형태를 정해줌으로써, risk를 줄일 수 있다.
impl PartialEq for Ticket {
fn eq(&self, other: &Self) -> bool {
let Ticket {
title,
description,
status,
} = self;
// [...]
}
}
Ticket의 정의가 바뀌면, compiler는 error를 낼 것이다.
또한 variable shadowing을 피하기 위해 struct fields의 이름을 다시 정의할 수 있다.
impl PartialEq for Ticket {
fn eq(&self, other: &Self) -> bool {
let Ticket {
title,
description,
status,
} = self;
let Ticket {
title: other_title,
description: other_description,
status: other_status,
} = other;
// [...]
}
}
Destructuring은 유용하지만, 이것보다 더 편리한 방법이 있다.
당신은 이전 exercises에서 몇몇 macro들을 이미 봤다.
- 테스트 케이스에서의 assert_eq!와 assert!
- console에 출력하기 위한 println!
Rust의 macro들은 code generators다.
그것들은 당신이 제공하는 input에 따라 새로운 Rust code를 만들고,
만들어진 코드는 당신의 program에 합쳐져서 컴파일된다.
몇몇 macro들은 Rust의 standard library에 있지만, 직접 만들 수도 있다.
이번 course에서는 직접 만들지는 않을 것이다.
몇몇 IDE들은 macro의 generated code를 확장시켜 검사하게 해준다.
만약 그것이 안되는 IDE라면, cargo-expand를 통해 검사할 수 있다.
Derive macro는 Rust macro의 특별한 특징이다.
그것은 구조체의 상위속성으로 지정된다.
#[derive(PartialEq)]
struct Ticket {
title: String,
description: String,
status: String
}
Derive macro들은 사용자 정의 type들의 common traits의 구현을 자동화해준다.
위의 예시로 설명하면, PartialEq trait은 Ticket을 위해 자동으로 구현된다.
macro를 expand하면, generated code가 읽기는 번거로울 수 있지만
기능적으로 당신이 직접 짠 것과 같다는 것을 볼 수 있다.
#[automatically_derived]
impl ::core::cmp::PartialEq for Ticket {
#[inline]
fn eq(&self, other: &Ticket) -> bool {
self.title == other.title && self.description == other.description
&& self.status == other.status
}
}
compiler는 가능하다면, derive traits를 하도록 유도한다.
04_derive exercise는 trait 만들고 구현하고 뻘짓 하다가
그냥 구조체에 Debug trait에 대한 derive macro만 추가하면 된다는 것을 알았다.

Rust compiler는 신이다.
지금까지 trait에 대한 두가지 use case를 봤다.
- built-in behaviour를 사용자 정의 하는 것(operator overloading같이)
- 기존 types에 새로운 behaviour를 넣는 것(extension traits와 같이)
그리고 세번째 use case가 있다.
그것은 generic programming이다.
지금까지 우리의 모든 function과 method는 concrete types로 작업했다.
concrete types은 명시적, 구체적인 types이며 정해진 type이다.
concrete types는 읽고 이해하기에 편하다.
하지만 재사용에 있어서 한계가 있다.
예를 들어 다음과 같은 상황을 상상해보자.
integer가 짝수면 true를 리턴하는 것을 원한다.
concrete types로 하면, 각 integer type에 해당하는 각각의 function을 직접 구현해야한다.
fn is_even_i32(n: i32) -> bool {
n % 2 == 0
}
fn is_even_i64(n: i64) -> bool {
n % 2 == 0
}
// Etc.
대신, 우리는 하나의 extension trait을 써서 각 integer type에 따른 다른 구현을 할 수 있다.
하지만, 이것 또한 중복된 과정이 포함되어 있다.
trait IsEven {
fn is_even(&self) -> bool;
}
impl IsEven for i32 {
fn is_even(&self) -> bool {
self % 2 == 0
}
}
impl IsEven for i64 {
fn is_even(&self) -> bool {
self % 2 == 0
}
}
// Etc.
우리는 이러한 과정에 generics를 사용할 수 있다.
Generics는 concrete type 대신 type parameter를 사용해 code를 쓸 수 있도록 한다.
fn print_if_even<T>(n: T)
where
T: IsEven + Debug
{
if n.is_even() {
println!("{n:?} is even");
}
}
print_if_even은 generic function이다.
특정 input type에 구애되지 않고 다음과 같은 모든 타입 T에 대해 작동한다.
- IsEven trait이 구현됐고
- Debug trait이 구현된
이러한 조건은 trait bound(T: IsEven + Debug)에 의해 표현된다.
+ symbol은 T implements가 multiple traits가 필요할 때 사용되고,
T: IsEven + Debug의 뜻은 T가 IsEven과 Debug를 구현한 곳이라는 뜻이다.
print_if_even에서 trait bounds는 무슨 역할을 할까?
이것을 알기 위해, 일단 지워보자.
fn print_if_even<T>(n: T) {
if n.is_even() {
println!("{n:?} is even");
}
}
이러면 당연히 compile이 안된다.
error[E0599]: no method named `is_even` found for type parameter `T` in the current scope
--> src/lib.rs:2:10
|
1 | fn print_if_even<T>(n: T) {
| - method `is_even` not found for this type parameter
2 | if n.is_even() {
| ^^^^^^^ method not found in `T`
error[E0277]: `T` doesn't implement `Debug`
--> src/lib.rs:3:19
|
3 | println!("{n:?} is even");
| ^^^^^ `T` cannot be formatted using `{:?}` because it doesn't implement `Debug`
|
help: consider restricting type parameter `T`
|
1 | fn print_if_even<T: std::fmt::Debug>(n: T) {
| +++++++++++++++++
trait bounds 없이 compliler는 T가 뭘 할 수 있는 지 알 수 없다.
T가 is_even method를 갖고 있는거나, T를 printing할 때의 format을 알 수 없다.
compiler의 관점에서 T는 behaviour가 없다.
Trait bounds는 function 본문에서 요구하는 behaviour가 존재하는지 확인하여,
사용할 수 있는 types를 제한한다.
위의 모든 예시들은 trait bounds를 나타내기 위해 where clause를 사용했다.
fn print_if_even<T>(n: T)
where
T: IsEven + Debug
// ^^^^^^^^^^^^^^^^^
// This is a `where` clause
{
// [...]
}
이것을 더 단순하게 하려면, 다음과 같이 type parameter 옆에 직접 inline하면 된다.
fn print_if_even<T: IsEven + Debug>(n: T) {
// ^^^^^^^^^^^^^^^^^
// This is an inline trait bound
// [...]
}
위의 예시들에서, T를 type parameter의 이름으로 사용했다.
이건 function이 오직 하나의 type parameter를 가질 때, 사용하는 관습이다.
다음과 같이 다른 것을 사용해도 된다.
fn print_if_even<Number: IsEven + Debug>(n: Number) {
// [...]
}
사실 여러 type parameter를 사용할 때, 의미있는 이름을 사용하는 것이 바람직하다.
type parameter의 이름을 지을 때도, 명확성과 가독성을 높이기 위해 변수와 함수 parameter하듯이 짓는다.
Rust의 관습에 따르면, type parameter 이름에는 upper camel case를 사용한다고 한다.
upper camel case: 단어를 이어붙일 때, 각 단어의 앞글자를 대문자로 하는 것
example) UpperCamelCase, GreenApple
아마 trait bounds가 왜 필요한지 궁금할 것이다.
function의 본문으로부터 요구되는 traits가 무엇인지 compiler가 추론하면 안되나?
할 순 있지만, 하지 않을 것이다.
그 이유는 function parameter의 explicit type annotations와 같다.
각 function signature는 caller와 callee와의 계약이고 각 정보는 명시적으로 알려줘야한다.
이것은 더 좋은 error messages와 더 좋은 documentation을 만들었고,
의도치않은 버전 충돌을 줄이고, 더 빠른 compilation 시간을 제공했다.
이 글로부터 유추하건데, 한마디로 하자면
코드 한줄을 추가함으로써, cost를 줄일 수 있다는 것이다.
그래서 이것을 안 할 이유가 있는가?
없다는 것이다.
05_trait_bounds exercise는 compile error를 없애는 것이었다.
function의 본문에서 비교 연산을 하는 부분이 있기에
trait bounds로 PartialOrd를 추가해주자 compile error가 없어졌다.
이전 chapter들을 통해 몇몇 string literals를 봤을 것이다.
그들에게 항상 .to_string() 호출이나 .into() 호출이 따라왔다.
이것을 왜 했는지 배울 때다!
당신이 string literal을 다음과 같이 쌍따음표(")를 이용해 정의할 수 있다.
let s = "Hello, world!";
s의 type은 &str이고, string slice의 reference다.
&str과 String은 다른 type이다.
이 두 type은 상호교환이 불가능하다.
이전에 배운 String memory layout을 상기시켜보자.
let mut s = String::with_capacity(5);
s.push_str("Hello");
이러면 memory는 다음과 같다.
+---------+--------+----------+
Stack | pointer | length | capacity |
| | | 5 | 5 |
+--|------+--------+----------+
|
|
v
+---+---+---+---+---+
Heap: | H | e | l | l | o |
+---+---+---+---+---+
당신이 기억한다면, &String은 memory에 어떻게 위치할 지 안다.
--------------------------------------
| |
+----v----+--------+----------+ +----|----+
| pointer | length | capacity | | pointer |
| | | 5 | 5 | | |
+----|----+--------+----------+ +---------+
| s &s
|
v
+---+---+---+---+---+
| H | e | l | l | o |
+---+---+---+---+---+
&String은 String의 metadata가 저장된 메모리 주소를 point한다.
위에 적혀진 pointer에 따르면, heap-allocated data인 string의 첫번째 byte, H를 가리킨다.
Hello 안의 ello와 같이 s의 substring을 표현하는 type을 얻고 싶을때는 어떻게 해야할까?
&str은 string에 대한 view이다.
즉, 다른 곳에 저장된 UTF-8 bytes sequence에 대한 참조다.
예를 들어, 다음과 같이 String으로 부터 &str을 만들 수 있다.
let mut s = String::with_capacity(5);
s.push_str("Hello");
// Create a string slice reference from the `String`, skipping the first byte.
let slice: &str = &s[1..];
메모리의 형태는 다음과 같다.
s slice
+---------+--------+----------+ +---------+--------+
Stack | pointer | length | capacity | | pointer | length |
| | | 5 | 5 | | | | 4 |
+----|----+--------+----------+ +----|----+--------+
| s |
| |
v |
+---+---+---+---+---+ |
Heap: | H | e | l | l | o | |
+---+---+---+---+---+ |
^ |
| |
+--------------------------------+
slice는 stack에 두 가지의 정보를 저장한다.
- slice의 첫번째 byte에 대한 pointer
- slice의 길이
slice는 data를 own하지는 않는다.
그저 가리키는 것 뿐이다.
그것은 String의 heap-allocated data에 대한 참조다.
slice가 drop될 때, heap-allocated data는 deallocate되지 않는다.
왜냐하면, s에 의해 아직 own되어있기 때문이다.
이게 slice가 capacity field를 갖지 않은 이유다.
data를 own하지 않기 때문에, 얼마나 많은 공간을 allocate 받은지 알 필요가 없다.
그저 참조하고자하는 data에 대한 것만 신경쓰면 된다.
경험상, textual data에 대한 참조가 필요할 때마다 &String보단 &str를 사용해라.
&str이 Rust code에서 일반적으로 더 관용적이고 유연하다.
만약 method가 &String을 return한다면, 참조를 반환하려는 텍스트와
정확히 일치하는 UTF-8 텍스트가 힙 어딘가에 있다고 약속하는 것이다.
만약 method가 &str을 return한다면, 좀 더 자유롭다.
어딘가에 text data가 많이 있고, 해당 text data에 대한 subset이 필요한 것과
일치하므로 해당 data에 대한 reference를 반환하는 것이라고 말하는 것이다.
06_str_slice에 대한 exercise는 이전에 accessor 구현에 대한
return type을 &str로 수정하는 작업이었다.

오늘 컨디션이 좋지 않아서인지, 내용이 많아서인지 모르겠지만
이해가 빠릿빠릿하게 되지 않았다.
macro는 spring boot를 배울 때, 사용한 macro와 비슷한 개념이라고 보면 될까?
그리고 trait bounds에 대한 것은 이전에 배운 개념으로 비슷하게 이해했다.
String slice에 대한 것도 이해했는데
&String과 &str에 대한 차이는 좀 더 해봐야 알 것 같다.
그래서 둘이 무엇이 크게 차이나는데?
실제로 어떤 결과가 펼쳐지는데?
이런 것들을 이후 exercises를 수행하면 알게되지 않을까?
뭔가 미지의 세계지만, 새로워서 재밌다.
'Rust Programming' 카테고리의 다른 글
| [Rust] 4. Traits: Associated and generic types, Clone trait, Copy trait (0) | 2024.07.30 |
|---|---|
| [Rust] 4. Traits: Deref trait, Sized trait, From trait (0) | 2024.07.29 |
| [Rust] 4. Traits: Trait, Orphan rule, Operator overloading (0) | 2024.07.25 |
| [Rust] 3. Ticket v1: Heap, References, Destructors (0) | 2024.07.24 |
| [Rust] 3. Ticket v1: Encapsulation, Ownership, Setters, Stack (0) | 2024.07.23 |