본문 바로가기

Rust Programming

[Rust] 4. Traits: Deref trait, Sized trait, From trait

이전 exercise에서 할 것이 별로 없었다.

 

그저 이 코드를

impl Ticket {
    pub fn title(&self) -> &String {
        &self.title
    }
}

 

이 코드로 바꾸면 끝이었다.

impl Ticket {
    pub fn title(&self) -> &str {
        &self.title
    }
}

 

하지만 뭔가 이상하다는 것을 눈치채야한다.


 

아래 사실들을 따라가보자.

  • self.title은 String이다.
  • 그래서 &self.title은 &String이다.
  • 수정된 title method의 output은 &str이다.

따라서 당신은 compile error를 예상했을 것이다.

Expected &String, found &str과 같은...

하지만, 동작한다. 왜?

 


 

Deref trait은 deref coercion이라는 language feature mechanism이다.

해당 trait은 std::ops module의 standard library에 정의되어 있다.

// I've slightly simplified the definition for now.
// We'll see the full definition later on.
pub trait Deref {
    type Target;
    
    fn deref(&self) -> &Self::Target;
}

 

type Target은 associated type이다.

그것은 trait이 구현될 때, 지정되어야 하는 구체적인 type에 대한 placeholder다.

 


 

type T에 대해 Deref<Target = U>를 구현함으로써,

&T와 &U가 상호교환가능하다고 compiler에게 말해주는 것이다.

 

특히, 다음과 같은 behavior를 할 수 있다.

  • T의 references는 U의 references로 implicitly하게 convert된다.
  • &self를 입력으로 사용하는 U에 정의된 모든 methods를 &T에서 호출할 수 있다.

dereference operator *에 대한 것도 있는데, 아직 필요하지 않아서 다루지 않겠다.

만약 궁금하다면, std의 docs를 보자!

 


 

String은 Target = str을 통해 Deref를 구현한다.

impl Deref for String {
    type Target = str;
    
    fn deref(&self) -> &str {
        // [...]
    }
}

 

이 구현과 deref coercion 덕분에, 필요할 때마다 &String은 &str로 자동으로 convert된다.

 


 

Deref coercion은 매우 강력한 특징이지만, 혼란을 일으킬 수 있다.

자동으로 type 변환 해주는 것은 코드를 읽고 이해하기 어렵게 한다.

T와 U에 같은 이름의 method가 정의되어 있다면, 어떤게 호출돼야할까?

 

이후 course에서 deref coercion에 대한 "safest" use case들을 확인할 것이다.

 


07_deref exercise를 진행했다.

document를 찾아보지말고 String의 양쪽 공백이 제거된 것을 반환하는 method를 만들어야했다.

시간이 지나도 갈피가 잡히지 않아 Solution을 확인했는데 그냥 허무했다.

deref를 통해 String type이 str type의 method를 사용할 수 있는 것을 확인하고 쓰는 것이었다.

 

답 보길 잘했다~~

 


 

&str에는 더 많은 것이 있다.

이전에 memory layouts에 대한 얘기를 했을 때,

&str이 stack에서 pointer로만 사용되어 usize일 것이라고 예상했을 것이다.

하지만 그게 전부가 아니고, &str은 pointer 다음에 slice의 길이를 metadata로 저장한다.

 

다음 예시를 보자.

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 |                  |
         +---+---+---+---+---+                  |
               ^                                |
               |                                |
               +--------------------------------+

 


 

str은 dynamically sized type이다.

DST는 compile time에 size를 알 수 없는 type이다.

&str과 같이 DST에 reference를 할 때마다,

가리키는 data에 대한 추가 정보를 포함해야한다.

그것이 fat pointer다.

&str의 경우에, pointer가 가리키는 slice의 길이를 저장한다.

남은 course에서 DST의 예시들을 더 볼 것이다.

 


 

Rust의 std library는 Sized라 불리는 trait을 정의한다.

pub trait Sized {
    // This is an empty trait, no methods to implement.
}

 

만약 compile time에 size를 알 수 있으면, 해당 type은 Sized다.

그게 아니라면, 해당 type은 DST다.

 


 

Sized는 marker trait의 첫번째 예시다.

marker trait은 구현돼야하는 어느 method도 필요하지 않은 trait이다.

그것은 어떤 behavior도 정의하지 않는다.

그냥 type이 특정 속성을 가지고 있다는 것만 표시하는 역할을 한다.

그런 다음 compiler는 mark를 보고 특정 behavior나 optimization을 하도록 한다.

 


 

또한 Sized는 auto trait이다.

당신은 명시적으로 그것을 구현할 필요가 없다.

compliler가 type definition에 따라 자동으로 구현해준다.

 


 

이전까지 본 Sized type은 u32, String, bool 등이 있다.

 

이제 막 본 str은 Sized가 아니다.

하지만 &str은 Sized다.

우리는 compile time에 &str의 size를 안다.

&str의 size는 두 개의 usize다.

하나는 pointer, 나머지 하나는 length로...

 


 

08_sized exercise는 compile error를 확인하는 것이었다.

Sized str을 확인했는데 str의 size는 compile time에 알 수 없다는 error가 떴다.

이건 뭐... 당연한 것이다.

 


 

string을 시작하게 된 곳으로 돌아가보자.

let ticket = Ticket::new("A title".into(), "A description".into(), "To-Do".into());

 

여기서 .into()가 무슨 역할을 하는지 이제는 안다.

 


 

아래 코드는 new method의 signature다.

impl Ticket {
    pub fn new(title: String, description: String, status: String) -> Self {
        // [...]
    }
}

 

우리는 "A title"과 같은 string literals의 type이 &str이라는것 또한 봤다.

우리는 여기서 type mismatch를 확인할 수 있다.

String을 예상했지만, &str이 들어온다.

이번에는 마법같은 변환이 되지 않을 것이다.

우리는 변환을 수행할 필요가 있다.

 


 

Rust의 standard library는 std::convert module에서 infallible conversion인 from과 into 두 개의 trait을 정의한다.

pub trait From<T>: Sized {
    fn from(value: T) -> Self;
}

pub trait Into<T>: Sized {
    fn into(self) -> T;
}

 

이러한 trait definition은 이전에 본 적 없는 몇가지 개념을 보여준다.

그것은 supertraits와 implicit trait bounds다.

이것에 대해 알아보자.


 

From: Sized 문법은 From이 Sized의 subtrait인 것을 알려준다.

이것은 From을 구현한 어떤 type이라도 Sized를 구현해야한다는 것을 의미한다.

이는 Sized가 From의 supertrait이라는 것과 같다.

 


 

generic type parameter가 있을 때마다, compiler는 implicitly하게 그것은 Sized라고 추정한다.

예를 들어,

pub struct Foo<T> {
    inner: T,
}

 

위 예시는 아래 예시와 같다.

pub struct Foo<T: Sized> 
{
    inner: T,
}

 

From<T>의 경우에, trait definition은 다음과 같다.

pub trait From<T: Sized>: Sized {
    fn from(value: T) -> Self;
}

 

former bound가 implicit이더라도, T와 type implementing From<T>는 Sized여야한다.

 


 

negative trait bound를 사용하여 implicit Sized bound를 해제할 수 있다.

pub struct Foo<T: ?Sized> {
    //            ^^^^^^^
    //            This is a negative trait bound
    inner: T,
}

 

이 문법은 "T는 Sized일수도 있고 아닐 수도 있다"를 의미한다.

그리고 T를 Foo<str>과 같은 DST에 bind할 수 있도록 해준다.

하지만, negative trait bounds는 Sized의 예외인 특별한 경우라서 다른 trait과 같이 사용할 수 없다.

 


 

std documentation에서 어떤 std type이 From trait을 구현하는지 확인할 수 있다.

당신은 String type이 From<&str>을 구현한 것을 확인할 수 있다.

그래서 우리는 다음과 같은 코드를 쓸 수 있다.

let title = String::from("A title");

 

하지만 우리는 주로 .into()를 썼다.

Into implementors를 확인해보면, &str에 Into<String>을 찾을 수 없을 것이다.

이게 어떻게 된 일일까?

 

From과 Into는 dual trait이다.

특히, Into는 blanket implementation을 사용하여 From을 구현하는 모든 type에 대해 구현된다.

impl<T, U> Into<U> for T
where
    U: From<T>,
{
    fn into(self) -> U {
        U::from(self)
    }
}

 

U type이 From<T>를 implement한다면, T type에는 Into<U>가 자동으로 구현된다.

그것이 let title = "A title".into();를 쓸 수 있는 이유다.

 


 

.into()를 볼 때마다, 당신은 type conversion을 목격하고 있는 것이다.

그럼 target type은 무엇일까?

 

대부분의 경우, target type은 둘 중 하나다.

  • function/method의 signature에 지정된다.(위의 Ticket::new의 return type과 같이)
  • variable declaration의 type annotation으로 지정된다.(let title: String = "A title".into();의 String과 같이)

.into()는 모호함이 없는 context로 부터 compiler가 target type을 추론할 수 있는 한, 잘 작동한다.

 


 

09_from exercise도 삽질을 많이 했다.

삽질을 많이 하다가 solution을 확인했는데 제대로 이해했다.

 

impl From<u32> for WrappingU32
{
    fn from(value: u32) -> Self{
        WrappingU32 { value }
    }
}

 

이 코드를 추가하면 됐는데

 

pub trait From<T: Sized>: Sized {
    fn from(value: T) -> Self;
}

 

위에서 배운 이 코드의 template을 활용한 impl 부분을 구현하는 것이었다.

 


 

이제 solutions를 확인하는 일이 생겼다.

처음 배우는 것이라 막히는 것은 당연하다고 생각하다.

그리고 삽질하는 과정이 낭비가 아닌

더욱 더 깊게 이해하게 되는 과정이라 생각하며 exercise에 임하고 있고

뒤에 아무리 어려운 내용이 나오더라도 꾸준히 반복하다보면

언젠가는 이해할 수 있다고 굳게 믿는다.