비욘드 JS: 러스트 - smart pointers 2

dante Yoon·2023년 1월 5일
2

beyond js

목록 보기
17/20
post-thumbnail

글을 시작하며

안녕하세요, 단테입니다.
오늘은 지난 스마트 포인터 포스팅 내용에 이어서 계속 스마트 포인터에 대해 알아보겠습니다.

Drop trait

이전 포스팅에서 Deref trait에 대해 알아보았는데요, 또 다른 중요한 스마트 포인터 패턴으로 Drop trait이 있습니다. 변수가 스코프를 벗어났을 때의 행동에 대해 정의한 것인데요, 파일이나 네트워크 커넥션 리소스를 해제할 때 사용하기도 합니다.

Drop trait은 스마트 포인터를 구현할 때 매우 자주 사용되기 때문에 잘 알고 있는 것이 중요합니다.
Box가 드롭되면 박스가 가르키고 있는 힙의 공간또한 할당 해제됩니다. 다른 언어들에서는 메모리나 소켓, 파일 핸들링같은 리소스나 메모리 사용을 명시적으로 해제해줘야 하는데 러스트에서는 값이 스코프를 벗어났을 때 실행해야할 코드를 선언할 수 있습니다. 컴파일러가 이 코드를 자동으로 실행시킵니다.

Drop trait를 구현하기 위해서 drop 메소드를 만들어주어야 하는데 이 메소드는 mutable reference self를 인자로 사용합니다. 예제코드로 알아보겠습니다.

struct CustomSmartPointer {
  data: String,
}

impl Drop for CustomSmartPointer {
  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.");
}

Droptrait은 prelude에 포함되어있기 때문에 스코프로 가져오지 않아도 됩니다. 우리는CustomSmartPointer에 Drop trait을 구현했고 drop 메소드를 구현했습니다. 이 drop 메소드는 println!을 호출합니다.

인스턴스가 스코프를 벗어날 때 실행시킬 로직들을 drop 함수의 바디에 작성하면 됩니다.

메인 함수에서 두 CustomSmartPointer 인스턴스를 생성했습니다. 메인 함수의 끝에서 CustomSmartPointer의 인스턴스가 스코프를 ㅂ서어날 것입니다. 러스트는 drop 메소드에 작성한 코드를 실행할 것입니다. 한번 봅시다.

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished dev [unoptimized + debuginfo] target(s) in 0.60s
     Running `target/debug/drop-example`
CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!

명시적으로 drop 메소드를 호출한 부분이 없음에도 불구하고 drop 메소드가 호출되었습니다.

각 변수들은 선언된 역순으로 drop 됩니다. 코드를 보시면 other stuff가 먼저 출력된 것을 볼 수 있습니다.

std::mem::drop

drop 기능을 사용하기로 했으면 중간에 멈출 수 없습니다. 그 누구도 drop을 멈출쑨 없쒀!!
보통 drop 기능을 불가능하게 만들지는 않습니다 Drop trait의 요점은 자동으로 실행되고 처리되는 것이기 때문입니다. 하지만 경우에 따라서 clean up 기능을 예상보다 일찍 실행해야 할 경우가 있습니다. 한 예제는 lock 기능을 관리하기 위해 스마트 포인터를 사용하는 것입니다. 락을 명시적으로 해제해야 동일 스코프에서 해당 락을 취득할 수 있기 때문입니다.

러스트에서는 이를 위해 Drop trait의 drop 메소드를 명시적으로 호출 가능하게 만들어놓지는 않았습니다.

그 대신에 표준 라이브러리의 std::mem::drop 함수를 사용해 스코프가 끝나기전에 drop을 호출할 수 있게 합니다.

fn main() {
  let c = CustomSmartPointer {
    data: String::from("some data"),
  };
  println!("CustomSmartPointer created.");
  c.drop();
  println!("CustomSmartPointer dropped before the end of main.");
}

앞서서 drop 메소드를 명시적으로 호출할 수 없다고 했기 때문에 당연히 에러가 발생합니다.

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
error[E0040]: explicit use of destructor method
  --> src/main.rs:16:7
   |
16 |     c.drop();
   |     --^^^^--
   |     | |
   |     | explicit destructor calls not allowed
   |     help: consider using `drop` function: `drop(c)`

For more information about this error, try `rustc --explain E0040`.
error: could not compile `drop-example` due to previous error

근데 잠시 에러 메세지를 살펴보면 explicit destructor calls not allowed라고 되어있습니다. destructor는 일반적인 프로그래밍 용어로 인스턴스를 정리(clean up)하는 함수를 말합니다. destructor와 상반되는 언어는 constructor로 클래스를 다뤄보신 분들이면 자주 들어보셨을 것입니다. 이것은 인스턴스를 만드는 함수입니다. drop 함수는 러스트에서 desturctor의 일종으로 동작합니다.

러스트에서 drop을 자동으로 수행하기 때문에 메인함수에서 두 번 호출하면 double free 에러가 발생해 해당 값을 두번에 걸쳐 clean up해 에러가 발생하는 것입니다.

따라서 명시적으로 일찍 값을 drop하기 위해 std::mem::drop 함수를 사용합시다.
이름은 같지만 std::mem::drop 함수는 Drop trait의 drop 함수와는 다른점이 있습니다. 우리는 drop 함수 내부에 할당해제하고자 하는 값을 인자로 넣어 호출합니다. 이 함수는 prelude이기 때문에 main 함수에서 drop 함수를 바로 호출할 수 있습니다.
``rust
fn main() {
let c = CustomSmartPointer {
data: String::from("some data"),
println!("CustomSmartPointer created.");
drop(c);
println!("CustomSmartPointer dropped before the end of main.");


```rust
$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished dev [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/drop-example`
CustomSmartPointer created.
Dropping CustomSmartPointer with data `some data`!
CustomSmartPointer dropped before the end of main.

Dropping CustomSmartPointer with data 'some data'!글자가 두 문장 가운데에 출력된 게 보이시죠?

지금까지 Box<T>와 스마트 포인터의 가장 중요한 특징에 대해 알아보았습니다.

Rc<T>

대개 소유권(ownership)은 명확합니다. 어떤 변수가 어떤 값을 가지고 있는지 잘 알 수 있습니다.

하지만 한 값을 여러 개의 소유자가 공유하는 경우도 발생합니다 예를 들어 graph data 자료구조의 경우 여러 소유자가 한 값에 대한 소유권을 공유합니다. 여러 개의 엣지가 동일한 노드를 가리키므로 노드는 개념적으로 여러 엣지에 의해 소유됩니다.

노드는 아무런 엣지에 의해 가르켜지지 않는 후에야 clean up 처리될 수 있습니다.
러스트에서 Rc<T> 타입을 이용해 명시적으로 특정 값에 대한 공동 소유권을 정의할 수 있습니다.
Rc는 reference counting의 줄임말인데 값에 대한 소유권을 가지고 있는 레퍼런스들을 계속 추적합니다. 만약 값에 대한 레퍼런스가 한개도 없으면 해당 값을 cleanup 합니다.

Rc<T> 타입을 거실의 티비라고 해볼게요. 처음 거실로 들어온 사람이 티비를 보려고 리모컨을 누릅니다.
다른 가족들이 집에와서 같이 티비를 보기 시작했습니다. 마지막 사람이 거실을 벗어나면 아무도 이제 사용하는 사람이 없으므로 티비의 전원을 끕니다. 만약 누군가 티비를 시청하는 중 꺼버린다면 고함이 터져나올 것입니다.

Rc<T> 타입은 힙에 데이터를 저장해 다른 프로그램들과 공유하기 위해 사용합니다. 우리는 컴파일 타임에 어떤 프로그램의 일부가 마지막으로 해당 데이터를 사용할지 알 수 없습니다.

Rc<T> 타입은 오직 싱글 스레드 시나리오에서만 사용합니다. 동시성 문제는 곧 다른 강의를 통해 다루도록 하겠습니다.

Rc<T>를 사용해 데이터를 공유해보자.

이전 포스팅에서 배웠던 con list를 생각해보세요. Box<T> 타입을 사용해 선언했었는데 이번에는 두 개의 리스트가 세번째 리스트를 공동 소유하고 있다고 하겠습니다. 개념적으로 위와 같은 그림으로 도식화 할 수 있을 것입니다.

5 다음에 10을 가지고 있는 리스트 a를 만들어볼 것이고 각각 3으로 시작하고 4로 시작하는 리스트 b, c를 만들겠습니다. b,c 는 a와 이어집니다.

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

use create::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));
}

위 코드는 컴파일에러가 발생하는데 로그를 봅시다.

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
  --> src/main.rs:11:30
   |
9  |     let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
   |         - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
11 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` due to previous error

Cons variant는 그들이 가지고 있는 데이터를 소유하게 되는데 따라서 b를 만들때 a는 b로 이동하고 b가 a를 소유하게 됩니다. 그리고 a를 c를 선언할 때 만들려고 하면 a가 이미 소유권을 b에 넘겼기 때문에 더 이상 move가 되지 않습니다.

그 대신에 우리는 List 가 Rc<T>Box<T>대신에 사용하게 하겠습니다.

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

use crate::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 타입은 prelude가 아니기 때문에 명시해주었습니다. 메인 함수에서 우리는 5, 10 값을 가지고 있는리스트를 만들었고 a라는 Rc<List> 타입 변수에 패턴 바인딩 했습니다. 그리고 우리는 b,c 를 생성했는데 Rc::clone을 통해 인자로 Rc<List> a의 레퍼런스를 전달했습니다.

a.clone 함수 호출 대신에 Rc::clone(&a) 를 사용한 이유는 이 경우 러스트의 컨벤션이 Rc::clone을 사용하는 것을 일반적으로 보기 때문입니다. Rc::clone 의 구현은 clone이 하는 것처럼 모든 데이터를 깊은 복사(deep copy) 하지 않습니다. 그대신 레퍼런스 카운트를 증가해 시간이 많이 걸리지 않게 합니다. 깊은 복사는 시간이 많이 걸리는데 Rc::clone이 수행하는 레퍼런스 카운트는 성능상 이점을 가져다 줍니다.

러스트에서 레퍼런스 카운트는 특정 값에 대한 레퍼런스를 추적한 값을 의미합니다. 이 카운트가 0이 되면 값은 자동으로 할당해제됩니다.

Rc<T> cloning을 통해 reference 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));
}

위 코드에서 inner scope를 생성했고 내부에 리스트 c를 선언했습니다. c가 스코프를 벗어날 때 reference count가 어떻게 되는지 살펴보겠습니다.

프로그램의 각 부분에서 reference count가 변화할 때마다 reference count를 출력하겠습니다.
Rc::string_count 함수 호출을 통해 이 count를 얻을 수 있습니다. storng_conut라고 명명되는 이유는 Rc<T> 타입이 weak_count라는 메소드를 가지고 있기 때문입니다.

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished dev [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/cons-list`
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

a에서 1, b 선언 이후 2등 clone을 호출할 때마다 1씩 count가 증가되는 것을 알 수 있습니다. c가 스코프를 벗어나면 count가 다시 줄어듭니다.

RefCell<T>, Interior Mutability Pattern

데이터에 대한 불변 레퍼런스일지라도 데이터를 변경할 수 있게 해주는 러스트의 디자인 패턴을 interior mutability pattern이라고 합니다. 보통 borrow checker에 의해 이러한 작업은 불가능합니다. 데이터를 변경하기 위해 unsafe 코드를 데이터를 사용해서 예외상황을 만듭니다. unsafe 코드는 컴파일러에게 컴파일러 체크를 의존하는 것이 아닌 수동 체크를 통해코드를 작성하겠다고 말하며 코드 체크의 책임을 작성자에게 위임합니다.

이 패턴은 우리가 런타임에 버로우 규칙을 준수할 수 있음을 보장할때만 사용할 수 있습니다.

이 패턴을 사용하기 위해 사용할 RefCell<T>를 살펴보겠습니다.
Rc 타입과는 다르게 Refcell 타입은 데이터에 대한 단일 소유권만을 가질 수 있습니다.
Box와늩 무엇이 다를까요? 버로우 규칙에대해 기억하시나요?

  • 한번에 하나의 mutable reference나 여러 개의 immutable reference를 가질 수있다. (mutable reference, immutable reference 모두를 가지는 것은 불가능)
  • 레퍼런스는 항상 유효해야 한다.

Box 타입에서 위의 규칙은 항상 컴파일 타임에 강제됩니다. RefCell을 사용할 때 위 규칙들은 런타임에 강제됩니다. 따라서 레퍼런스들에 대해 이 규칙을 위반하게 되면 컴파일 에러가 발생하며 RefCell에 대해 규칙을 위반하게 되면 패닉이 발생하고 프로그램이 종료됩니다.

컴파일 타임에 버로우 규칙을 확인하는 것은 개발 과정 중 빨리 에러를 발견할 수 있는 장점을 주고 런타임 퍼포먼스에 영향을 주지 않습니다. 이러한 이유에서 버로우 규칙들을 컴파일 타임에 확인하는 것은 대부분 최선의 선택이며 러스트에서 기본 사항으로 채택하고 있는 사항입니다.

반대로 런타임에 버로우 규칙을 확인하는 것의 장점은 특정 memory-safe한 시나리오 작성이 허용 가능하다는 것인데 이러한 코드는 컴파일 체크를 수행할 때는 허용되지 않습니다. 본질적으로 러스트 컴파일러의 정적 분석은 보수적이고 코드의 몇 부분은 정적 분석이 어려울 수 있습니다. 대표적으로 Halting Problem이 있습니다.

몇 가지 분석이 불가능하기 때문에 러스트 컴파일러는 코드 컴파일이 소유권 규칙이 준수되면서 진행되는 것을 보장하지 못하고 몇 정확한 프로그램을 틀렸다고 할 수도 있습니다. 이러한 문제는 프로그램을 할 때 불편한 부분이기 때문에 RefCell을 사용해 컴파일러가 이해하지 못하는 부분이 있으면 버로우 규칙을 준수한다는 게 보장된다는 조건 하에 이런 코드를 실행시킬 수 있습니다.

Box, Rc, RefCell을 고르는 기준

  • Rc는 동일한 데이터에 대한 여러 소유자를 가질 수 있습니다. Box, RefCell은 하나의 소유자만 가질 수 있습니다.

  • Box는 컴파일 타임에 immutable / mutable borrow 검사를 할 수 있습니다. Rc는 컴파일 타임에 immutable borrow만 하는게 가능하며 RefCell은 런타임에 immutable, mutable borrow 검사가 가능합니다.

  • RefCell이 런타임에 mubtable borrow검사가 가능하기 때문에 RefCell가 immutable하더라도 내부 데이터를 변경할 수 있습니다.

Interior Mutability - 불변 값에 대해 mutable borrow 수행하기

borrowing 규칙으로 인해 불변 값을 mutable하게 borrow 할 수 없습니다. 예를 들면 다음과 같습니다.

fn main() {
  let x = 5;
  let y = &mut x;
}
$ cargo run
   Compiling borrowing v0.1.0 (file:///projects/borrowing)
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
 --> src/main.rs:3:13
  |
2 |     let x = 5;
  |         - help: consider changing this to be mutable: `mut x`
3 |     let y = &mut x;
  |             ^^^^^^ cannot borrow as mutable

For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` due to previous error

cannot borrow as mutable이라는 에러 힌트가 보이죠? 가끔은 메소드 내부에서의 값 자체를 변경하고 외부에서만 해당 값을 변경할 수 없게 해야 하는 경우가 있습니다. 외부 스코프에 있는 메소드는 값을 변경시킬 수 없게 말입니다. RefCell<T>를 이용해 interior mutabilty를 구현할 수 있습니다.

하지만 RefCell은 borrowing rule로 부터 완전히 자유롭지는 않습니다. borrow checker는 컴파일 타임에서는 이를 검사하지 않는 대신에 런타임에 borrowing rule을 검사합니다. 이 규칙을 어기면 패닉이 발생하는 것을 감당해야 합니다.

Interior Mutability 사용 예제 - Mock Objects

테스트를 진행을 위해 기존에 작성했던 타입 정보 대신에 다른 타입 정보를 가져다가 사용하는 경우가 있습니다. 이것을 일반적으로 placeholder type이라고 부르는데 test double이라는 용어로 불리기도 합니다. 영화를 찍을 때 엑스트라 배우가 주연을 대신해 배역을 연기하는 것 처럼 말입니다.

테스트에서 스턴트 역할을 맡는 객체는 Mock objects입니다. test double 타입의 일종으로 테스트 진행동안 무슨 일이 일어났는지 기록하며 나중에 검증 대상이 됩니다.

러스트에서는 다른 언어에서 제공하는 mock object 역할을 하는 객체가 없습니다. 이를 struct를 만듬으로 대신할 수 있습니다.

테스트 시나리오를 세워보려고 합니다.
최댓 값에 대해 현재 값이 얼마나 근사한지에 대해 검사하는 라이브러리를 만들 것입니다.
이 라이브러리의 사용 예시는 API 최대 호출 가능 수에 대해 현재 얼마나 클라이언트가 API를 호출했는지를 추적하는 용도로 사용될 수 있을 것입니다.

이 라이브러리를 사용하는 어플리케이션은 현재 허용량까지 얼마나 호출 수가 근접했는지를 알려주는 메신저 기능이 필요할 텐데, 우리가 만들 라이브러리에서는 우리가 제공할 Messenger trait을 라이브러리를 사용하는 측에서 구현할 거라는 사실만 알고 있으면 됩니다.

pub trait Messenger {
  fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
  messenger: &'a T,
  value: usize,
  max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
  T: Messenger,
{
  pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
    LimitTracker {
      messenger,
      value: 0,
      max,
    }
 }
 
 pub fn set_value(&mut self, value: usize) {
   self.value = value;
   
   let percentage_of_max = self.value as f64 / self.max as f64;
   
   if percentage_of_max >= 1.0 {
       self.messenger.send("Error: You are over your quota!");
   } else if percentage_of_max >= 0.9 {
       self.messenger
           .send("Urgent warning: You've used up over 90% of your quota!");
   } else if percentage_of_max >= 0.75 {
       self.messenger
           .send("Warning: You've used up over 75% of your quota!");
   }
 }
}

위 코드에서 눈여겨 봐야 할 것은 Messenger trait의 send 메소드가 immutable reference인 self와 immutable reference인 텍스트 메시지를 인수로 가지고 있다는 것입니다. 이 trait은 우리의 mocking 객체가 구현해야할 것입니다.

우리는 LimitTracker의 set_value 메소드를 테스트해봐야 하는데요, value 파라메터로 넘길 값을 변경할 수 있는데 반해 호출하는 측에서 assertion할 수 있는 어떤 값도 반환하지 않는 다는 것을 알 수 있습니다. 우리가 LimiTracker 인스턴스의 set_value 메소드를 호출했을 때 다른 value 값을 넘김에 따라 messenger는 적절한 메세지를 보내라는 정보를 받아야 할 것입니다.

send 메소드를 호출할 때마다 실제로 이메일을 보낼 수는 없으니 얼마나 많은 메시지 발신을 받았는지를 기록할 수있는 mock 객체를 필요로 합니다.

모의 객체를 하나 만들고, 모의 객체를 사용하는 LimitTracker를 만들고, LimitTracker에서 set_value 메서드를 호출한다음, 모의 객체에 우리가 예상한 메세지가 잘 갔는지 확인해야 합니다.

#[cfg(test)]
mod tests {
    use super::*;

    struct MockMessenger {
        sent_messages: Vec<String>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: vec![],
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.len(), 1);
    }
}

이 코드의 MockMessenger struct에 sent_message라는 벡터 타입 필드가 있는데, 보낸 메세지를 추적하는 용도로 사용합니다. 우리는 associated function인 new를 가지고 있는데 이를 통해 빈 메시지 리스트를 가진 MockMessenger 인스턴스를 쉽게 생성할 수 있습니다.

MockMessenger에 대한 Messenger trait을 만들었습니다. 이를 통해 MockMessenger의 인스턴스를 LimitTracker에서 사용할 수 있습니다.

send 메소드 정의를 보면 메세지를 파라메터로 받아 MockMessenger의 sent_messagees 필드에 저장함을 확인할 수 있습니다.

it_sends_an_over_75_percent_warning_message를 보면 LimitTracker에 75보다 큰 값이 주어졌을 때 어떻게 되는지 테스트 합니다. 우리는 LimitTracker에서 80이라는 값을 설정하게 했습니다.

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
  --> src/lib.rs:58:13
   |
2  |     fn send(&self, msg: &str);
   |             ----- help: consider changing that to be a mutable reference: `&mut self`
...
58 |             self.sent_messages.push(String::from(message));
   |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable

For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` due to previous error
warning: build failed, waiting for other jobs to finish...

그런데 실제 테스트 코들르 돌려보면 send메소드가 &self 라는 immutable reference self를 받기 때문에 MockMessenger를 변경할 수 없습니다. &mut를 사용해 위 컴파일 에러를 수정하고 싶지만 그럴 수 없습니다. 왜냐면 send 메소드의 시그니처는 Messenger trait 정의와 맞지 않기 때문입니다.

이를 해결하기 위해 interior mutability를 사용하겠습니다.sent_messages를 RefCell에 담으면 sent 메소드가 sent_messages를 변경할 수 있을 것입니다.

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.borrow_mut().push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        // --snip--

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}

이제 sent_messages 필드는 RefCell<Vec<String>> 타입입니다. send 메소드 내부를 보면 RefCell<Vec<string>>borrow_mut를 호출함으로 인해 RefCell<Vec<String>>의 내부 값에 대한 mutable reference를 얻었습니다. 이제 push 메소드를 사용할 수 있습니다.

it_sends_an_over_75_percent_warning_message 메소드 호출을 마지막으로 mock_messenger.sent_messages에서 borrow 메소드를 호출해 RefCell<Vec<String>> 에서 벡터에 대한 immutable reference를 얻어 호출회수와 비교했습니다.

borrow를 런타임에 추적하는 RefCell

가변/불변 레퍼런스를 생성할 때 각각 &, &mut 문법을 사용하는 것과 같이 RefCell<T>스마트 포인터를 사용할때는 borrow와 borrow_mut을 사용합니다. borrow 메소드는 Ref<T>타입의 스마트포인터를 반환하고 borrow_mut는 RefMut<T> 타입의 스마트포인터를 반환합니다. 두 타입 모두 Deref를 구현해서 일반 레퍼런스와 같이 다룰 수 있습니다.

RefCell<T>는 현재 얼마나 많은 Ref<T>, RefMut<T> 스마트포인터들이 동작 중인지 추적합니다. borrow를 호출할 때마다 RefCell<T>는 immutable borrow 카운트를 증가시키고 Ref<T> 값이 스코프를 벗어나면 카운트를 감소합니다. 컴파일 타임의 borrow 규칙과 같이 RefCell<T>여러 개의 불변 borrow 혹은 하나의 가변 borrow를 동시에 가질 수 있게 합니다.

만약 버로우 규칙을 어기면, 레퍼런스를 사용할 때 발생하는 컴파일 에러가 아니라 런타임 에러, 즉 패닉이 RefCell<T>의 구현체에서 발생합니다.

아래 코드에서 send 메소드를 구현하는데 두 가변 borrows를 동일 스코프에서 생성해보겠습니다. RefCell이 어떻게 반응하는지 볼 것입니다.

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            let mut one_borrow = self.sent_messages.borrow_mut();
            let mut two_borrow = self.sent_messages.borrow_mut();

            one_borrow.push(String::from(message));
            two_borrow.push(String::from(message));
        }
    }

Ref_mut를 위한 one_borrow 변수를 만들고 two_borrow 변수를 만들었습니다. 이 두 가변 레퍼런스는 동일 스코프에 있는데 버로우 규칙에 위배되는 상황입니다. 이 코드를 가지고 테스트를 수행하면 컴파일 에러는 발생하지 않으나 런타임 에러가 발생합니다.

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
    Finished test [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/limit_tracker-e599811fa246dbde)

running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED

failures:

---- tests::it_sends_an_over_75_percent_warning_message stdout ----
thread 'main' panicked at 'already borrowed: BorrowMutError', src/lib.rs:60:53
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_sends_an_over_75_percent_warning_message

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

에러 문구를 보면 already borrowed: BorrowMutError가 발생합니다. RefCell<T>가 런타임에 borrow rule을 어떻게 준수하는지 확인 할 수 있습니다.

가변 데이터에 대해 여러 개의 오너를 가지게 하기

RefCell은 Rc와 함께 자주 사용됩니다. Rc가 한 데이터에 여러 개의 오너쉽을 가질 수 있게 했다는 것을 상기시켜보세요. 하지만 해당 다중 오너쉽은 불변 데이터에 대해서만 가능했습니다. Rc<T> 타입이 RefCell 타입을 가지고 있게 하면 여러 개의 오너쉽을 가질 수 있는 동시에 mutate 할 수 있게 됩니다.

Rc가 여러 개의 리스트의 소유권을 여러 대상과 공유하고 있을 떄 Rc가 불변 값을 가지고 있기 때문에 리스트는 생성된 이후 변경될 수 없습니다. RefCell을 사용해 리스트의 값을 변경할 수 있게 해봅시다.

#[derive(Debug)]
enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

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(3)), Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));

    *value.borrow_mut() += 10;

    println!("a after = {:?}", a);
    println!("b after = {:?}", b);
    println!("c after = {:?}", c);
}

위의 예제 코드에서 Rc<RefCell<i32>>의 인스턴스를 생성하고 value라는 변수에 저장했습니다. 변수 a가 value를 가지고 있는 Cons variant를 이용한 List를 가지게 했습니다.

value를 clone해야 a, value가 내부 값 5에 대한 소유권을 공유할 수 있기 때문에 clone 메소드를 사용했습니다.

a를 생설할때 리스트를 Rc<T>로 감쌌는데 이 덕분에 리스트 b,c는 a를 참조할 수 있습니다.

a,b,c를 생성한 이후 value에 10을 더하고 싶습니다. value에 borrow_mut를 호출했는데, borrow_mut 메소드는 RefMut<T> 스마트 포인터를 리턴하고 역참조 연산자에 의해 해당 값에 변경을 가할 수 있게 되었습니다.

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished dev [unoptimized + debuginfo] target(s) in 0.63s
     Running `target/debug/cons-list`
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))

이 패턴은 앞으로 러스트로 프로그램을 작성하며 자주 사용하게 될지도 모릅니다.
RefCell T를 사용함으로 우리는 외부적으로 불변한 리스트 값을 가지게 되고 RefCell T의 메소드를 통해 interior mutability를 수행해 원하는 스코프에서 데이터에 변경을 가할 수 있습니다 RefCell은 멀티스레드 환경의 코드에서는 동작하지 않습니다.

글을 마치며

이번 스마트 포인터는 정말 긴 여정이었습니다.

머리가 뒤죽박죽일지도 모르겠습니다.
괜찮습니다. 한번 빠르게 훑어보시고 나중에 다시 공부해도 괜찮습니다.

수고 많으셨습니다!

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글