Skip to content

스마트 포인터 활용

  • 스마트 포인터(Smart Pointer)는 포인터처럼 작동하지만 추가적인 메타데이터와 능력들도 가지고 있는 데이터 구조이다.

  • StringVec<T>는 스마트 포인터이다.

  • 스마트 포인터는 보통 구조체를 이용하여 구현되어 있다. 스마트 포인터가 일반적인 구조체와 구분되는 특성은 스마트 포인터가 DerefDrop 트레잇을 구현한다는 것이다.

  • Deref 트레잇은 스마트 포인터 구조체의 인스턴스가 참조자처럼 동작하도록 하여, 참조자나 스마트 포인터 둘 중 하나와 함께 작동하는 코드를 작성하게 해준다.

    • 스마트 포인터가 평범한 참조자처럼 취급될 수 있도록 구현한다.

    • Deref를 구현한 구조체에 대해 *로 값을 참조하면, deref 함수를 호출한 후 *를 한번 호출하는 것으로 대치된다. (소유권 이전을 막기 위해 이런 식으로 구현되어있다.)

    • Rust는 타입이 맞지 않는 경우 역참조를 강제하기 때문에 함수와 메소드 호출을 작성할 때 &*를 이용한 명시적 참조 및 역참조를 생략할 수 있다. Deref::deref를 구현하면 커스텀 구조체에서도 이것이 가능해진다.

      fn hello(name: &str) {
      println!("Hello, {}!", name);
      }
      fn main() {
      let m = MyBox::new(String::from("Rust"));
      // 역참조 강제가 있기 때문에 가능한 코드이다.
      hello(&m);
      // 역참조 강제가 없었다면 이렇게 작성해야한다.
      hello(&(*m)[..]);
      }
    • 가변 참조자에 대한 *를 오버라이딩하기 위해선 DerefMut 트레잇을 사용해야한다.

    • 러스트는 다음의 세 가지 경우에 역참조 강제를 수행한다.

      • T: Deref<Target=U>일때 &T에서 &U
      • T: DerefMut<Target=U>일때 &mut T에서 &mut U
      • T: Deref<Target=U>일때 &mut T에서 &U
    • 불변 참조자는 가변 참조자로 강제될 수 없다.

  • Drop 트레잇은 스마트 포인터의 인스턴스가 스코프 밖으로 벗어났을 때 실행되는 코드를 커스터마이징 가능하도록 해준다.

    • 파일이나 네트워크 연결 같은 자원을 해제하는 데에 사용될 수도 있다.

    • 변수들은 만들어진 순서의 역순으로 Drop된다.

      ```rust
      struct CustomSmartPointer {
      data: String,
      }
      impl Drop for CustomSmartPointer {
      // Drop Trait을 구현하면 drop 메서드가 drop시 실행된다.
      fn drop(&mut self) {
      println!("Dropping CustomSmartPointer with data `{}`!", self.data);
      }
      }
      fn main() {
      let c = CustomSmartPointer { data: String::from("my stuff") };
      let d = CustomSmartPointer { data: String::from("other stuff") };
      println!("CustomSmartPointers created.");
      }
      /*
      출력 결과:
      CustomSmartPointers created.
      Dropping CustomSmartPointer with data `other stuff`!
      Dropping CustomSmartPointer with data `my stuff`!
      */
      ```
    • Double free 문제가 생길 수 있기 때문에 drop() 함수를 직접 호출하는 것은 허용되지 않는다. 대신 std::mem::drop를 사용하여 메모리에서 직접 지울 수 있다.

  • 스마트 포인터는 Rust에서 자주 활용되는 패턴이므로, 직접 비슷한 구조로 구현할 수 있다.

대표적인 스마트 포인터

  • 표준 라이브러리의 대표적인 스마트 포인터들에 대해 알아보자.

값을 힙에 할당하기 위한 Box<T>

  • Box<T>는 데이터를 스택이 아니라 힙에 저장할 수 있도록 해준다.

  • 아래와 같은 상황에서 사용할 수 있다.

    • 컴파일 타임에 크기를 알 수 없는 타입을 갖지만, 사이즈를 아는 상태에서 해당 타입의 값을 이용하고 싶을 때
    • 커다란 데이터의 데이터를 복사하지 않고 소유권을 옮기기를 원할 때
      • 박스 안의 힙에 큰 데이터를 저장하면, 작은 양의 포인터 데이터만 스택 상에서 복사되고 데이터는 힙의 한 곳에 머물게 된다.
    • 어떤 값의 소유와 타입에 관계없이 특정 트레잇을 구현한 타입이라는 점만 신경 쓰고 싶을 때
  • new를 사용해서 생성할 수 있다.

fn main() {
let b = Box::new(5);
println!("b = {}", b);
}
  • 박스는 재귀적 타입을 가능하게 한다.
    • 현재 아이템의 값과 다음 아이템을 저장하는 Pair(Cons)가 있다고 해보자.

      enum List {
      Cons(i32, List),
      Nil,
      }
      use List::{Cons, Nil};
      fn main() {
      let list = Cons(1, Cons(2, Cons(3, Nil)));
      }
    • Nil을 값으로 가지게 함으로써 Cons가 계속해서 이어지는 걸 막을 수 있지만, 컴파일러가 variants를 계산할 때는 무한대의 메모리가 필요한 타입인 것으로 해석한다. 따라서 위의 코드는 실행할 수 없다.

      image
    • Box<T>를 사용하면 무한한 variants를 가졌던 위와 달리, 한정된 크기를 가지는 pointer 값만을 가지기 때문에 문제가 해결된다.

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

복수개의 소유권을 가능하게 하는 참조 카운팅 타입 Rc<T>

  • 대부분의 경우에서, 소유권은 한 변수당 하나로 명확하다.

  • 하지만 하나의 값이 여러 개의 소유자를 가질 수도 있는 경우가 있다.

    • 예를 들면, 그래프 데이터 구조에서, 여러 에지가 동일한 노드를 가리킬 수 있다. 그 노드는 개념적으로 해당 노드를 가리키는 모든 에지들에 의해 소유된다. 노드는 어떠한 에지도 이를 가리키지 않을 때까지는 메모리 정리가 되어서는 안된다.
  • 복수 소유권을 가능하게 하기 위해서, 러스트는 Rc<T>라는 타입을 가지고 있다. Reference Counting의 약자이고, 이 타입은 어떤 값이 계속 사용되는지 혹은 그렇지 않은지를 알기 위해 해당 값에 대한 참조자의 갯수를 계속 추적한다.

  • 만일 값에 대한 참조자가 0개라면, 그 값은 어떠한 참조자도 무효화하지 않고 메모리에서 정리될 수 있다.

  • 프로그램의 여러 부분에서 읽을 데이터를 힙에 할당하고 싶고, 어떤 부분이 그 데이터를 마지막에 이용하게 될지 컴파일 타임에 알 수 없는 경우 Rc<T> 타입을 사용한다.

  • Rc<T>는 오직 단일 스레드 상에서만 사용 가능하다.

  • 예제를 살펴보자.

image
  • 이러한 구조를 나타내는 코드가 있다.

    enum List {
    Cons(i32, Box<List>),
    Nil,
    }
    use List::{Cons, Nil};
    fn main() {
    let a = Cons(5,
    Box::new(Cons(10,
    Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
    }
  • a에 대한 소유권을 두 군데에 지정하는 것이기 때문에 위 코드는 컴파일 되지 않는다. (참조자를 사용하는 경우 a가 사라지지 않는다는 것을 보장할 수 없다.)

  • Box<T>의 자리에 Rc<T>를 이용하여 정의하면 이러한 문제를 해결할 수 있다.

enum List {
Cons(i32, Rc<List>),
Nil,
}
use List::{Cons, Nil};
use std::rc::Rc;
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
let b = Cons(3, Rc::clone(&a));
let c = Cons(4, Rc::clone(&a));
}
  • Rc::clone의 호출은 오직 참조 카운트만 증가시킨다.
  • Rc::strong_count 함수를 호출함으로써 카운트 값을 얻을 수 있다.
fn main() {
let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
println!("count after creating a = {}", Rc::strong_count(&a));
let b = Cons(3, Rc::clone(&a));
println!("count after creating b = {}", Rc::strong_count(&a));
{
let c = Cons(4, Rc::clone(&a));
println!("count after creating c = {}", Rc::strong_count(&a));
}
println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}
/*
출력결과:
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2
*/
  • Rc<T>는 읽기 전용으로 우리 프로그램의 여러 부분 사이에서 데이터를 공유하도록 허용해준다.

빌림 규칙을 컴파일 타임 대신 런타임에 강제하는 타입인, RefCell<T>를 통해 접근 가능한 Ref와 RefMut

  • Rc<T>와는 다르게, RefCell<T> 타입은 가지고 있는 데이터 상에 단일 소유권을 나타낸다.

  • Box<T>는 하나의 가변 참조자 혹은 임의 개수의 불변 참조자를 가질 수 있고 항상 유효해야 한다는 것을 컴파일러가 강하게 제약한다.

  • 하지만 RefCell<T>는 런타임에 검사된 가변 빌림을 허용하기 때문에, RefCell<T>이 불변일 때라도 RefCell<T> 내부의 값을 변경할 수 있다. 즉, 코드가 빌림 규칙을 따르는 것을 확신하지만 컴파일러는 이를 이해하고 보장할 수 없을 경우 유용하게 쓰인다.

  • 이 코드는 Rust에 의해 컴파일될 수 없다. (cannot borrow immutable local variable x as mutable)

    fn main() {
    let x = 5;
    let y = &mut x;
    }
  • RefCell<T>를 사용하면 아래와 같이 수정할 수 있다.

    use std::cell::RefCell;
    fn main() {
    let x = RefCell::new(5);
    let y = &mut *x.borrow_mut();
    }
  • 컴파일러 내의 빌림 검사기는 이러한 내부 가변성을 허용하는 대신 런타임에 검사를 수행한다. 만약 규칙이 위반된다면 컴파일러 에러 대신 panic!을 얻을 것이다.

  • RefCell<T> 또한 단일 스레드 상에서만 사용 가능하다.

Rc<T>RefCell<T>의 조합

  • Rc<T>는 어떤 데이터에 대해 복수의 소유자를 허용하지만, 그 데이터에 대한 불변 접근만 제공한다.

    • 그러므로 만약 RefCell<T>을 들고 있는 Rc<T>를 선언한다면, 변경 가능하면서 복수의 소유자를 갖는 값을 가질 수 있다!
  • 어떤 리스트의 소유권을 공유하는 여러 개의 리스트를 가질 수 있도록 하기 위해 Rc<T>를 사용했던 예제를 떠올려보자.

    • 이전에는 Rc<T>가 불변의 값만을 가질 수 있었다.

    • 이 리스트 안의 값을 변경할 수 있게 하기 위해서 RefCell<T>를 사용할 수 있다.

      ```rust
      #[derive(Debug)]
      enum List {
      Cons(Rc<RefCell<i32>>, Rc<List>),
      Nil,
      }
      use List::{Cons, Nil};
      use std::rc::Rc;
      use std::cell::RefCell;
      fn main() {
      let value = Rc::new(RefCell::new(5));
      let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));
      let b = Cons(Rc::new(RefCell::new(6)), Rc::clone(&a));
      let c = Cons(Rc::new(RefCell::new(10)), Rc::clone(&a));
      *value.borrow_mut() += 10;
      println!("a after = {:?}", a);
      println!("b after = {:?}", b);
      println!("c after = {:?}", c);
      }
      ```

참고