반응형

간단하게 정리해본 Rust 강좌입니다. 

다음 문서를 기반으로 작성했습니다.

 

https://tourofrust.com/00_ko.html 

https://rinthel.github.io/rust-lang-book-ko/ch15-00-smart-pointers.html 



2022. 11. 13 최초작성

2023. 05. 10 최종작성

 

Rust 개발 환경 만드는 방법은 아래 포스트를 참고하세요.

https://webnautes.tistory.com/2110





스마트 포인터는 C/C++의 포인터처럼 작동하지만 추가적인 메타 데이터와 기능을 가지고 있습니다. 

 

소유권과 빌림의 개념을 가지고 있는 러스트에서, 참조자와 스마트 포인터 간의 차이점은 참조자는 데이터에 대한 소유권을 가지지 않고 단지  데이터를 빌리는 포인터인 반면 스마트 포인터는 가리키고 있는 데이터를 소유할 수 있습니다.  

 

Box<T>는 데이터를 heap에 저장할 수있도록 해주는 스마트 포인터입니다.

 

String이나 Vec<T>도 스마트 포인터입니다. 왜냐하면 이들이 메모리를 소유하면서  다룰수 있기 때문입니다. 또한 소유하고 있는 메모리 크기 정보 등의 메타데이터와 String이 항상 유효한 UTF-8일 것임을 보장하는 것 등의 추가 기능을 갖고 있습니다.

 

스마트 포인터는 보통 구조체를 이용하여 구현됩니다.  스마트 포인터가 구조체와 구분되는 점은 스마트 포인터가 Deref 트레잇과 Drop 트레잇을 구현한다는 점입니다. Deref 트레잇은 스마트 포인터 구조체의 인스턴스가 참조자처럼 동작하도록 하여 참조자나 스마트 포인터 둘 중 하나와 함께 작동하는 코드를 작성하게 해 줍니다. Drop 트레잇은 스마트 포인터의 인스턴스가 스코프 밖으로 벗어나 인스턴스가 소멸할때 실행되는 코드를 커스터마이징 할 수있도록 해 줍니다. 



Box

Box는 가장 직관적인 스마트 포인터로 데이터 타입은 Box<T>입니다. 데이터를 힙에 저장하고  스택에 힙에 저장된 데이터를 가리키는 포인터를 저장합니다. 이렇게 함으로 인해 발생하는 오버헤드는 없으며 Box는 메모리상에서 데이터가 차지하는는 크기를 알 수 있습니다. 

 

Box를 사용하게 되는 경우는 다음과 같습니다. 

  • 컴파일 타임에 크기를 알 수 없는 데이터 타입을 사용시 Box를 사용하면  메모리를 사용하는 크기를 알 수 있습니다. 
  • 커다란 데이터를 가지고 있고 소유권을 옮기고 싶지만 그렇게 했을 때 데이터가 복사되지 않을 것이라고 보장하기를 원할 때입니다. 예를 들어 방대한 양의 데이터의 소유권 옮길때 긴 시간이 소요될 수 있는데 이는 그 데이터가 스택 상에서 복사되기 때문입니다. 이러한 상황에서 성능을 향상하기 위해서, Box 안의 힙에 그 방대한 양의 데이터를 저장할 수 있습니다. 그러면, 작은 양의 포인터 데이터만 스택 상에서 복사되고, 데이터는 힙 상에서 한 곳에 머물게 됩니다. 
  • 어떤 값을 소유하고 이 값의 구체화된 데이터 타입을 알고 있기보다는 특정 트레잇을 구현한 데이터 타입이라는 점만 신경 쓰고 싶을 때. 트레잇 객체를 말합니다.

 

아래 코드는 Box를 사용하여 heap에 i32 데이터 타입의 데이터 값을 저장하는 예제입니다. 

fn main() {

    // Box를 사용하여 heap에 저장된 값 5를 가리키는 변수 b를 생성합니다.
    let b = Box::new(5);

    println!("b = {}", b);
// 이 곳에서 블록을 벗어나므로 포인터인 변수 b가 stack에서 삭제되며 변수 b가 가리키는 heap에 저장된 값 5가 heap에서 삭제됩니다.



실행 결과

b = 5



Box를 사용한 재귀적인 데이터 타입 정의

컴파일 타임에 Rust는 특정 데이터 타입이 얼마나 많은 공간을 차지하는지를 알 필요가 있습니다. 

 

재귀적 데이터 타입 (recursive data type)은 컴파일 타임에 크기를 알 수 없는 데이터 타입입니다. 왜냐하면 재귀적 데이터 타입은 이전 데이터 타입 값에 새로운 데이터를 추가하여 새로운 데이터 타입 값이 되는 것을 재귀적으로 반복하기 때문입니다.  이론적으로 무한히 계속될 수 있기 때문에 재귀적 데이터 타입의 값이 메모리에서 얼마큼의 공간을 차지할지 알 수 없습니다. 

 

Box는 알려진 크기를 갖고 있으므로, 재귀적 데이터 타입 정의 내에 Box를 넣음으로써 재귀적 데이터 타입의 크기를 알 수 있습니다.

 

Box가 어떻게 재귀적 데이터 타입을 정의하도록 해주는지 cons list 데이터 타입을 예로 들어 살펴봅니다. 

cons list 내의 각 아이템은 두 개의 요소로 구성됩니다.  새로 추가할 값과 이전 아이템값입니다.  cons list의 마지막 아이템값은 Nil 이라 불리는 값을 갖습니다.  

 

cons list는 Cons 함수를 재귀적으로 호출함으로써 만들어집니다. 

 

아래 코드는 cons list를 위한 열거형 정의입니다.  List 타입이 알려진 크기를 가지고 있지 않고 있기 때문에 Rust에서는 사용할 수 없습니다. 

enum List {
    Cons(i32, List),
    Nil,
}



다음 코드는 List 타입을 이용하여 숫자 1, 2, 3을 저장하는 코드입니다.  

use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

 

변수 list에 저장되는 값을 풀어적어보면 다음과 같습니다.

 

첫 번째 List는 1이전 List를 갖습니다. 

let list = Cons(1, Cons(2, Cons(3, Nil)));

 

두번째 List는  2이전 List를 갖습니다. 

let list = Cons(1, Cons(2, Cons(3, Nil)));

 

세번째 List는  3이전 List를 갖습니다.  이 List는 마지막 아이템이므로  이전 List는 Nil이 됩니다. 

let list = Cons(1, Cons(2, Cons(3, Nil)));



List 데이터 타입의 크기를 알 수 없기 때문에 아래 코드를 컴파일 하려고 하면 

enum List {
    Cons(i32, List),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}



다음과 같은 에러가 발생합니다.

error[E0072]: recursive type `List` has infinite size



에러는 List 데이터 타입이 무한한 크기를 갖는다는 것을 알려줍니다. 

recursive type `List` has infinite size

 

왜냐하면 재귀적으로 List 데이터 타입을 정의했기 때문입니다. List 데이터 타입의 값은 또다른 List  데이터 타입 값을 갖습니다.  결과적으로, Rust는 List 데이터 타입의 값을 저장하는데 필요한 크기를 알 수 없습니다. 

 

Cons 함수의 리턴값  i32 데이터 타입의 값과 List 데이터 타입의 값을 갖습니다. 따라서 Cons 값은  i32 데이터 타입의 크기 +  List 데이터 타입 크기 만큼의  공간을 필요로 합니다. List 데이터 타입의 값이 필요한 크기를 알아보려고 하면 다시 Cons 함수의 리턴턴 값을 살펴봐야 합니다. 이렇게 무한히 이 과정이 반복되기 때문에 List 데이터 타입의 값이 갖는 크기를 알 수 없습니다.   아래 그림이 이 과정을 보여줍니다. 



Rust는 재귀적으로 정의된 데이터 타입을 위하여 얼마큼의 공간을 할당해야 하는지 알아낼 수 없습니다. 

 

앞에서 발생한 에러를 다시보면 다음과 같은 제안을 하고 있습니다. 

값을 직접 저장하는 대신, 간접적으로 값의 포인터를 저장하는 데이터 구조로 바꿀 수 있음을 의미합니다.

 = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to make `List` representable



Cons의 두번째 값으로 List 타입의 값을 넣는 대신 Box<T>를 넣을 수 있다는 뜻입니다. Box<T>는 Cons 안에 있기보다는 heap에 있을 List 값을 가리킬 것입니다. 

 

Box<T>가 포인터이기 때문에, Rust는 Box<T>가 필요로 하는 공간을 알 수 있습니다. 포인터의 크기는 그것이 가리키고 있는 데이터의 양에 따라 달라지지 않습니다. 

 

다음 코드는 Box<T>를 사용하도록 수정한 코드입니다. 문제없이 컴파일이 됩니다. 

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1,
        Box::new(Cons(2,
            Box::new(Cons(3,
                Box::new(Nil))))));
}



Cons 함수의 리턴값은 i32 데이터 타입 +  Box의 포인터를 저장할 공간이 필요합니다.  Nil 값은  아무런 값도 저장하지 않으므로, Cons 값에 비해 공간을 덜 필요로 합니다. 이제 컴파일러는 List 값을 저장하는데 필요한 크기를 알아낼 수 있습니다.  구조를 아래 그림으로 보여줍니다. 



Deref 트레잇

Deref 트레잇을 사용하면 스마트 포인터를 참조자처럼 취급할 수 있습니다. 

Deref 트레잇을 구현함으로써 역참조 연산자 * 의 동작을 커스터마이징 할 수 있기때문입니다.

 

다음 다음 코드에서 i32 데이터 타입의 값에 대한 참조자를 생성하고는 참조자를 따라가서 값을 얻기 위해 역참조 연산자 *를 사용합니다:

fn main() {
    let x = 5;


    // 참조자 y를 생성합니다.
    let y = &x;

    // 정수 5와 변수 x의 값은 같습니다.
    assert_eq!(5, x);

    // 정수 5와 참조자 y를 역참조한 값은 같습니다.
    assert_eq!(5, *y);
}



위 코드를 참조자 대신에 Box<T>를 사용하도록 해도 동일한 방식으로 작동합니다. 

fn main() {
    let x = 5;


    // 참조 대신에 Box를 사용했습니다.
    let y = Box::new(x);

    // 정수 5와 변수 x의 값은 같습니다

    assert_eq!(5, x);

    // 참조자를 썼을 때와 동일한 방식으로 역참조를 사용해도 똑같이 동작합니다.
    assert_eq!(5, *y);
}




Box<T> 타입은 하나의 요소를 가진 튜플 구조체로 정의됩니다. 아래 코드는 MyBox<T> 타입을 Box<T> 타입과 동일한 방식으로 정의하였습니다. 

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}



MyBox라는 이름의 구조체를 정의하고 제네릭 파라미터 T를 선언했는데, 이는 우리의 타입이 어떠한 종류의 타입 값이든 가질 수 있길 원하기 때문입니다. MyBox 타입은 T 타입의 하나의 요소를 가진 튜플 구조체입니다. MyBox::new 함수는 T 타입인 하나의 파라미터를 받아서 그 값을 갖는 MyBox 인스턴스를 반환합니다.

 

앞에서 살펴봤던 코드에서 Box<T> 대신 새로 정의한 MyBox<T>를 이용하도록 수정해봅니다. 컴파일시 에러가 발생합니다. 왜냐하면 Rust는  MyBox가 어떻게 역참조 하는지 모르기 때문입니다:

error[E0614]: type `MyBox<{integer}>` cannot be dereferenced

 

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);


    // 역참조시 에러가 발생합니다.
    assert_eq!(5, *y);
}





MyBox<T> 타입은 역참조 될 수 없는데 그 이유는 해당 기능을 아직 구현하지 않았기 때문입니다. * 연산자로 역참조를 가능케 하기 위해서, Deref 트레잇을 구현해야 합니다.

 

트레잇을 구현하기 위해서는 트레잇의 요구 메소드들에 대한 구현체를 제공할 필요가 있습니다. 표준 라이브러리가 제공하는 Deref 트레잇은 self를 빌려서 내부 데이터에 대한 참조자를 반환하는 deref라는 이름의 메소드 하나를 구현하도록 요구합니다. 

 

아래 코드는 MyBox의 정의에 Deref의 구현을 추가했습니다. 이제 역참조가 정상적으로 실행됩니다.

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    // 연관 타입(associated type) 정의
    type Target = T;

    fn deref(&self) -> &T {
       // * 연산자를 이용해 접근하고자 하는 값의 참조자를 반환합니다.
        &self.0
    }
}


fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);

    println!("{}", *y);
}

 

실행해보면 변수 x의 값을 참조한 y에 의해서 값이 출력됩니다.

5



코드에서 다음처럼 역참조를 할때

*y

 

Rust는 다음 코드를 실행하게 됩니다. 

*(y.deref())

 

deref 메소드가 값의 참조자를 반환하고 *(y.deref())에서의 괄호 바깥의 역참조가 여전히 필요한 이유는 소유권 때문입니다. 

 

 

간단하게 정리해본 Rust 강좌 1 : 변수, 상수, 함수, 튜플, 배열, 데이터타입, println

https://webnautes.tistory.com/2194

 

간단하게 정리해본 Rust 강좌 2 : if-else, loop, while, for, match, struct, method

https://webnautes.tistory.com/2195

 

간단하게 정리해본 Rust 강좌 3 : 열거형, 제네릭 데이터 타입, Option, Result, unwrap, vector

https://webnautes.tistory.com/2196

 

간단하게 정리해본 Rust 강좌 4 : 소유권, 참조, 역참조, 생명주기

https://webnautes.tistory.com/2197

 

간단하게 정리해본 Rust 강좌 5 : 문자열, utf-8

https://webnautes.tistory.com/2198

 

간단하게 정리해본 Rust 강좌 6 : 모듈

https://webnautes.tistory.com/2199

 

간단하게 정리해본 Rust 강좌 7 : &self, &mut self, trait, 동적 디스패치, 정적 디스패치, Generic 메서드, Box

https://webnautes.tistory.com/2200

 

간단하게 정리해본 Rust 강좌 8 : 참조자, 댕글링 참조, 원시 포인터

https://webnautes.tistory.com/2203

 

간단하게 정리해본 Rust 강좌 9 : Box, 재귀적 데이터타입, Deref 트레잇

https://webnautes.tistory.com/2202

 

반응형

문제 발생시 지나치지 마시고 댓글 남겨주시면 가능한 빨리 답장드립니다.

도움이 되셨다면 토스아이디로 후원해주세요.
https://toss.me/momo2024


제가 쓴 책도 한번 검토해보세요 ^^

+ Recent posts