Rc<T>
, 참조 카운트 스마트 포인터
대부분의 경우, 소유권은 명확하다. 특정 값이 어떤 변수에 의해 소유되는지 정확히 알 수 있다. 하지만 어떤 경우에는 하나의 값이 여러 소유자를 가질 수 있다. 예를 들어, 그래프 데이터 구조에서 여러 간선이 동일한 노드를 가리킬 수 있으며, 이 노드는 개념적으로 그 노드를 가리키는 모든 간선에 의해 소유된다. 노드는 더 이상 간선이 없어 소유자가 없는 경우에만 정리되어야 한다.
Rust에서는 Rc<T>
타입을 사용해 명시적으로 다중 소유권을 활성화해야 한다. Rc<T>
는 참조 카운팅(reference counting) 의 약자로, 이 타입은 값에 대한 참조 수를 추적해 값이 여전히 사용 중인지 여부를 결정한다. 값에 대한 참조가 0개가 되면, 그 값은 더 이상 사용되지 않으므로 정리할 수 있다.
Rc<T>
를 가족 방에 있는 TV라고 상상해 보자. 한 사람이 방에 들어와 TV를 켜면, 다른 사람들도 방에 들어와 TV를 볼 수 있다. 마지막 사람이 방을 나갈 때, 더 이상 TV가 사용되지 않으므로 TV를 끈다. 만약 누군가가 다른 사람들이 아직 TV를 보고 있는데 TV를 꺼버리면, 남아 있는 시청자들이 불만을 가질 것이다!
Rc<T>
타입은 프로그램의 여러 부분이 읽을 수 있도록 힙에 데이터를 할당하고, 컴파일 타임에 어떤 부분이 데이터를 마지막으로 사용할지 결정할 수 없을 때 사용한다. 만약 어떤 부분이 마지막으로 데이터를 사용할지 알고 있다면, 그 부분을 데이터의 소유자로 만들고, 컴파일 타임에 적용되는 일반적인 소유권 규칙을 따를 수 있다.
Rc<T>
는 단일 스레드 시나리오에서만 사용된다는 점에 유의해야 한다. 16장에서 동시성을 다룰 때, 멀티스레드 프로그램에서 참조 카운팅을 어떻게 수행하는지 설명할 것이다.
Rc<T>
를 사용해 데이터 공유하기
리스트 15-5의 cons 리스트 예제로 돌아가 보자. 이전에는 Box<T>
를 사용해 리스트를 정의했다. 이번에는 두 개의 리스트가 세 번째 리스트를 공유하는 상황을 만들어 볼 것이다. 개념적으로는 그림 15-3과 비슷한 구조다.
그림 15-3: 리스트 b
와 c
가 리스트 a
를 공유하는 구조
먼저 5와 10을 포함하는 리스트 a
를 만든다. 그런 다음 두 개의 리스트 b
와 c
를 생성한다. b
는 3으로 시작하고, c
는 4로 시작한다. 그리고 두 리스트 모두 5와 10을 포함하는 리스트 a
를 이어받는다. 즉, 두 리스트가 5와 10을 포함하는 리스트를 공유하게 된다.
이 시나리오를 Box<T>
를 사용해 구현하려고 하면 리스트 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
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
변형은 자신이 포함한 데이터의 소유권을 갖는다. 따라서 리스트 b
를 생성할 때 a
는 b
로 이동하고, b
가 a
를 소유하게 된다. 그런 다음 리스트 c
를 생성할 때 다시 a
를 사용하려고 하면, a
가 이미 이동되었기 때문에 허용되지 않는다.
Cons
의 정의를 변경해 참조를 포함하도록 할 수도 있지만, 그렇게 하면 라이프타임 매개변수를 명시해야 한다. 라이프타임 매개변수를 명시하면 리스트의 모든 요소가 적어도 리스트 전체만큼 오래 살아있음을 보장한다. 리스트 15-17의 요소와 리스트는 이 조건을 만족하지만, 모든 상황에서 그렇지는 않다.
대신 Box<T>
대신 Rc<T>
를 사용해 List
를 정의한다. 리스트 15-18에서 볼 수 있듯이, 각 Cons
변형은 값과 List
를 가리키는 Rc<T>
를 포함한다. b
를 생성할 때 a
의 소유권을 가져가는 대신, a
가 포함하는 Rc<List>
를 복제한다. 이렇게 하면 참조 수가 1에서 2로 증가하고, a
와 b
가 Rc<List>
의 데이터를 공유하게 된다. c
를 생성할 때도 a
를 복제해 참조 수를 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>
를 스코프로 가져오기 위해 use
문을 추가해야 한다. main
함수에서는 5와 10을 포함하는 리스트를 생성하고, 이를 a
라는 새로운 Rc<List>
에 저장한다. 그런 다음 b
와 c
를 생성할 때 Rc::clone
함수를 호출하고, a
에 있는 Rc<List>
의 참조를 인자로 전달한다.
a.clone()
대신 Rc::clone(&a)
를 호출할 수도 있지만, 러스트에서는 이 경우 Rc::clone
을 사용하는 것이 관례다. Rc::clone
의 구현은 대부분의 타입의 clone
구현과 달리 모든 데이터를 깊은 복사하지 않는다. Rc::clone
호출은 참조 수만 증가시키며, 이는 시간이 거의 걸리지 않는다. 데이터의 깊은 복사는 시간이 많이 걸릴 수 있다. Rc::clone
을 사용해 참조 수를 증가시키면, 깊은 복사와 참조 수 증가를 시각적으로 구분할 수 있다. 코드에서 성능 문제를 찾을 때는 깊은 복사만 고려하면 되고, Rc::clone
호출은 무시해도 된다.
Rc<T>
를 복제하면 참조 카운트가 증가한다
리스트 15-18의 예제를 수정해 a
에 있는 Rc<List>
에 대한 참조를 생성하고 제거할 때 참조 카운트가 어떻게 변하는지 살펴보자.
리스트 15-19에서는 main
함수를 수정해 리스트 c
주위에 내부 스코프를 추가한다. 이렇게 하면 c
가 스코프를 벗어날 때 참조 카운트가 어떻게 변하는지 확인할 수 있다.
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))))); 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)); }
프로그램에서 참조 카운트가 변하는 각 지점에서 Rc::strong_count
함수를 호출해 참조 카운트를 출력한다. 이 함수는 count
가 아닌 strong_count
라는 이름을 사용하는데, 그 이유는 Rc<T>
타입이 weak_count
도 가지고 있기 때문이다. weak_count
의 용도는 “Weak<T>
를 사용해 참조 순환 방지하기”에서 살펴볼 것이다.
이 코드는 다음과 같은 결과를 출력한다:
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished `dev` profile [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
에 있는 Rc<List>
의 초기 참조 카운트는 1이다. 이후 clone
을 호출할 때마다 카운트가 1씩 증가한다. c
가 스코프를 벗어나면 카운트가 1 감소한다. 참조 카운트를 증가시키기 위해 Rc::clone
을 호출해야 하는 것과 달리, 참조 카운트를 감소시키기 위해 별도의 함수를 호출할 필요는 없다. Rc<T>
값이 스코프를 벗어나면 Drop
트레이트의 구현이 자동으로 참조 카운트를 감소시킨다.
이 예제에서는 볼 수 없지만, main
함수의 끝에서 b
와 a
가 스코프를 벗어나면 카운트가 0이 되고, Rc<List>
는 완전히 정리된다. Rc<T>
를 사용하면 단일 값이 여러 소유자를 가질 수 있으며, 카운트는 소유자 중 하나라도 존재하는 한 값이 유효하도록 보장한다.
Rc<T>
는 불변 참조를 통해 프로그램의 여러 부분에서 데이터를 읽기 전용으로 공유할 수 있게 해준다. 만약 Rc<T>
가 여러 가변 참조도 허용한다면, 4장에서 다룬 빌림 규칙 중 하나를 위반할 수 있다. 동일한 위치에 대한 여러 가변 빌림은 데이터 경쟁과 불일치를 초래할 수 있다. 하지만 데이터를 변경할 수 있는 기능은 매우 유용하다! 다음 섹션에서는 내부 가변성 패턴과 Rc<T>
와 함께 사용할 수 있는 RefCell<T>
타입에 대해 논의할 것이다.