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과 비슷합니다.
그림 15-3: 세 번째 리스트 a 를 함께 소유하는 두 리스트 b, c
우리는 값 5, 10 을 담은 리스트 a 를 만들고, 그 다음 3 으로 시작하는 리스트
b, 4 로 시작하는 리스트 c 를 만들 것입니다. b 와 c 는 이후 둘 다
값 5, 10 이 들어 있는 첫 번째 리스트 a 로 이어집니다. 즉, 두 리스트가
같은 꼬리 부분을 공유하는 것입니다.
이 상황을 Box<T> 를 사용하는 List 정의로 구현하려 하면, 목록 15-17처럼
동작하지 않습니다.
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));
}
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 를 만들 때
a 는 b 안으로 이동하고, 이제 b 가 a 를 소유합니다. 그런 뒤 리스트 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로 늘리고, a 와 b 가
그 Rc<List> 안의 데이터를 함께 소유하게 만듭니다. c 를 만들 때도 a 를 다시
clone 하면 참조 수가 2에서 3이 됩니다. Rc::clone 을 호출할 때마다 Rc<List>
안의 데이터에 대한 참조 수가 늘어나며, 참조 수가 0이 되기 전까지는 데이터가 정리되지
않습니다.
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<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 가 만들어졌다가 스코프를 벗어날 때 어떤 일이
일어나는지 확인할 수 있습니다.
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));
}
branch 를 내부 스코프에서 만들고 strong/weak 참조 수 확인하기leaf 가 만들어진 직후, 그 Rc<Node> 의 strong count 는 1이고 weak count 는
0입니다. 내부 스코프에서 branch 를 만들고 leaf 와 연결하면, branch 안의
Rc<Node> 는 strong count 1, weak count 1을 갖게 됩니다(leaf.parent 가
Weak<Node> 로 branch 를 가리키기 때문입니다). 그리고 leaf 의 count를 출력하면,
branch.children 안에 저장된 leaf 의 Rc<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” 을 읽어 보세요.
다음 장에서는 러스트의 동시성을 다룹니다. 그 과정에서 새로운 스마트 포인터도 몇 가지 더 만나게 됩니다.