Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rc<T>, 참조 카운트 스마트 포인터

대부분의 경우 소유권은 분명합니다. 어떤 변수가 특정 값을 소유하는지 정확히 알 수 있습니다. 하지만 하나의 값이 여러 소유자를 가져야 하는 경우도 있습니다. 예를 들어 그래프 자료구조에서는 여러 간선이 같은 노드를 가리킬 수 있고, 개념적으로 그 노드는 그 노드를 가리키는 모든 간선의 소유물이 됩니다. 따라서 그 노드를 가리키는 간선이 하나도 남지 않아 소유자가 없어지기 전에는 그 노드를 정리하면 안 됩니다.

러스트의 Rc<T> 타입은 reference counting 의 약자로, 이런 다중 소유권을 명시적으로 가능하게 해 줍니다. Rc<T> 는 어떤 값에 대한 참조 수를 추적해서, 그 값이 여전히 사용 중인지 아닌지를 판단합니다. 어떤 값에 대한 참조가 0개가 되면, 더 이상 유효하지 않은 참조 없이 그 값을 정리할 수 있습니다.

Rc<T> 를 가족 거실의 TV 라고 생각해도 좋습니다. 한 사람이 들어와 TV를 보기 시작하면 TV를 켭니다. 다른 사람들도 들어와 같이 볼 수 있습니다. 마지막 사람이 거실을 나갈 때 그들은 TV를 끕니다. 더 이상 사용되지 않으니까요. 만약 아직 다른 사람이 보고 있는데 누군가 TV를 꺼 버린다면, 남은 시청자들의 불만이 터져 나오겠지요!

우리는 프로그램의 여러 부분이 힙에 있는 어떤 데이터를 읽어야 하지만, 컴파일 시점에는 그 중 어느 부분이 마지막까지 그 데이터를 쓸지 알 수 없는 경우 Rc<T> 타입을 사용합니다. 만약 어떤 부분이 마지막인지 알고 있다면, 그냥 그 부분을 데이터의 소유자로 삼고 러스트의 일반 소유권 규칙을 그대로 적용할 수 있었을 것입니다.

Rc<T> 는 오직 단일 스레드 상황에서만 사용할 수 있다는 점에 주의하세요. 16장에서 동시성을 다룰 때, 멀티스레드 프로그램에서 참조 카운팅을 어떻게 하는지도 살펴봅니다.

데이터 공유하기

이제 목록 15-5의 cons list 예제로 다시 돌아갑시다. 그때는 Box<T> 를 사용해 정의했습니다. 이번에는 세 번째 리스트 하나를 두 개의 다른 리스트가 함께 소유하는 구조를 만들어 보겠습니다. 개념적으로는 그림 15-3과 비슷합니다.

A linked list with the label 'a' pointing to three elements. The first element contains the integer 5 and points to the second element. Th
e second element contains the integer 10 and points to the third element. The third element contains the value 'Nil' that signifies the end of the l
ist; it does not point anywhere. A linked list with the label 'b' points to an element that contains the integer 3 and points to the first element o
f list 'a'. A linked list with the label 'c' points to an element that contains the integer 4 and also points to the first element of list 'a' so th
at the tails of lists 'b' and 'c' are both list 'a'.

그림 15-3: 세 번째 리스트 a 를 함께 소유하는 두 리스트 b, c

우리는 값 5, 10 을 담은 리스트 a 를 만들고, 그 다음 3 으로 시작하는 리스트 b, 4 로 시작하는 리스트 c 를 만들 것입니다. bc 는 이후 둘 다 값 5, 10 이 들어 있는 첫 번째 리스트 a 로 이어집니다. 즉, 두 리스트가 같은 꼬리 부분을 공유하는 것입니다.

이 상황을 Box<T> 를 사용하는 List 정의로 구현하려 하면, 목록 15-17처럼 동작하지 않습니다.

Filename: src/main.rs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::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));
}
Listing 15-17: Box<T> 를 사용하는 두 리스트가 세 번째 리스트의 소유권을 공유하려 하면 허용되지 않음을 보여 주기

이 코드를 컴파일하면 다음과 같은 오류가 납니다.

$ 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
   |
note: if `List` implemented `Clone`, you could clone the value
  --> src/main.rs:1:1
   |
 1 | enum List {
   | ^^^^^^^^^ consider implementing `Clone` for this type
...
10 |     let b = Cons(3, Box::new(a));
   |                              - you could clone this value

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

Cons variant는 자신이 담는 데이터를 직접 소유합니다. 따라서 리스트 b 를 만들 때 ab 안으로 이동하고, 이제 ba 를 소유합니다. 그런 뒤 리스트 c 를 만들 때 다시 a 를 쓰려고 하면, 이미 a 가 이동되었기 때문에 사용할 수 없습니다.

Cons 정의를 참조를 들도록 바꿀 수도 있지만, 그렇게 하면 라이프타임 매개변수까지 지정해야 합니다. 라이프타임을 적는다는 것은 리스트의 모든 요소가 그 리스트 전체만큼 오래 살아야 함을 뜻하는데, 목록 15-17에서는 그렇지만 모든 상황에서 항상 그렇지는 않습니다.

그래서 이번에는 List 정의에서 Box<T> 대신 Rc<T> 를 사용하겠습니다. 목록 15-18을 보세요. 이제 각 Cons variant는 값 하나와 List 를 가리키는 Rc<T> 를 담게 됩니다. b 를 만들 때는 a 의 소유권을 가져가는 대신, a 가 가지고 있던 Rc<List>clone 해 참조 수를 1에서 2로 늘리고, ab 가 그 Rc<List> 안의 데이터를 함께 소유하게 만듭니다. c 를 만들 때도 a 를 다시 clone 하면 참조 수가 2에서 3이 됩니다. Rc::clone 을 호출할 때마다 Rc<List> 안의 데이터에 대한 참조 수가 늘어나며, 참조 수가 0이 되기 전까지는 데이터가 정리되지 않습니다.

Filename: src/main.rs
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));
}
Listing 15-18: Rc<T> 를 사용하는 List 정의

Rc<T> 는 prelude에 없기 때문에 먼저 use 문으로 스코프로 가져와야 합니다. main 에서 우리는 5, 10 을 담은 리스트를 만들고, 그 값을 Rc<List> 로 감싼 뒤 a 에 저장합니다. 그 다음 b, c 를 만들 때는 Rc::clone 함수를 호출해, a 안의 Rc<List> 에 대한 참조를 인수로 넘깁니다.

여기서는 Rc::clone(&a) 대신 a.clone() 라고 쓸 수도 있지만, 러스트 관례상 이 경우에는 Rc::clone 형식을 선호합니다. 대부분의 타입에 대한 clone 구현은 데이터 전체를 깊게 복사하지만, Rc::clone 의 구현은 참조 수만 올립니다. 이것은 거의 비용이 들지 않습니다. 데이터의 깊은 복사는 비용이 클 수 있기 때문에, 우리는 Rc::clone 표기 덕분에 “깊은 복사”와 “참조 수 증가”를 시각적으로 구분할 수 있습니다. 성능 문제를 볼 때도 깊은 복사는 신경 써야 하지만, Rc::clone 호출은 무시해도 된다는 뜻입니다.

클론해서 참조 수 늘리기

이제 목록 15-18의 예제를 약간 바꿔, a 안의 Rc<List> 에 대한 참조를 만들고 없앨 때 참조 수가 어떻게 바뀌는지 눈으로 보겠습니다.

목록 15-19에서는 main 안에 내부 스코프를 하나 추가하고, branch 생성 부분을 그 스코프 안으로 옮깁니다. 그러면 branch 가 만들어졌다가 스코프를 벗어날 때 어떤 일이 일어나는지 확인할 수 있습니다.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

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

// --snip--

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));
}
Listing 15-19: branch 를 내부 스코프에서 만들고 strong/weak 참조 수 확인하기

leaf 가 만들어진 직후, 그 Rc<Node> 의 strong count 는 1이고 weak count 는 0입니다. 내부 스코프에서 branch 를 만들고 leaf 와 연결하면, branch 안의 Rc<Node> 는 strong count 1, weak count 1을 갖게 됩니다(leaf.parentWeak<Node>branch 를 가리키기 때문입니다). 그리고 leaf 의 count를 출력하면, branch.children 안에 저장된 leafRc<Node> clone 때문에 strong count 가 2가 되었고, weak count 는 여전히 0인 것을 보게 됩니다.

내부 스코프가 끝나면 branch 는 스코프를 벗어나고, 그 Rc<Node> 의 strong count 는 0으로 줄어듭니다. 따라서 그 Node 는 drop 됩니다. leaf.parent 안의 weak count 1은 Node 가 drop 되는지 여부에는 영향을 주지 않으므로, 메모리 누수는 발생하지 않습니다!

스코프가 끝난 뒤 leaf 의 부모에 접근하려 하면 다시 None 을 얻게 됩니다. 프로그램 끝 시점에는 leaf 변수만이 그 Rc<Node> 를 가리키므로, leaf 안의 Rc<Node> 는 strong count 1, weak count 0을 갖게 됩니다.

참조 수 관리와 값 drop 로직은 모두 Rc<T>Weak<T>, 그리고 그것들의 Drop 구현 안에 들어 있습니다. Node 정의에서 “자식에서 부모로 가는 관계는 Weak<T> 참조다” 라고 지정함으로써, 부모 노드가 자식 노드를 가리키고 자식도 부모를 가리키면서도 참조 사이클과 메모리 누수는 만들지 않을 수 있는 것입니다.

정리

이 장에서는 일반 참조에 비해 서로 다른 보장과 트레이드오프를 제공하는 스마트 포인터를 어떻게 사용하는지 살펴보았습니다. Box<T> 는 크기가 알려져 있고, 힙에 할당된 데이터를 가리킵니다. Rc<T> 는 힙 데이터에 대한 참조 수를 추적해, 하나의 데이터를 여러 소유자가 함께 가질 수 있게 해 줍니다. RefCell<T> 는 내부 가변성을 통해, 겉보기엔 불변인 타입 안의 값을 바꿀 수 있게 하고, 대여 규칙을 컴파일 시점이 아니라 런타임에서 검사합니다.

또한 스마트 포인터 기능의 많은 부분을 가능하게 하는 Deref, Drop 트레이트도 다루었습니다. 메모리 누수를 일으킬 수 있는 참조 사이클을 살펴보고, Weak<T> 를 사용해 이를 방지하는 방법도 배웠습니다.

이 장이 흥미를 자극했고 여러분만의 스마트 포인터를 직접 구현해 보고 싶다면, “The Rustonomicon” 을 읽어 보세요.

다음 장에서는 러스트의 동시성을 다룹니다. 그 과정에서 새로운 스마트 포인터도 몇 가지 더 만나게 됩니다.