Box<T>와 힙 데이터
Box로 값을 힙에 저장하고 컴파일 시 크기를 알 수 없는 재귀적 타입에 포인터 간접 계층을 도입합니다.
포인터(pointer)는 메모리의 주소를 담는 변수를 가리키는 일반적인 개념입니다. 이 주소는 다른 데이터를 가리킵니다. 러스트에서 가장 흔한 포인터는 참조와 대여에서 배운 참조입니다. 참조는 & 기호로 나타내며 자신이 가리키는 값을 빌립니다. 참조에는 데이터를 가리키는 것 말고 특별한 기능이 없으며, 추가 비용도 없습니다.
반면 스마트 포인터(smart pointer)는 포인터처럼 동작하면서 추가 메타데이터와 기능을 가진 자료 구조입니다. 스마트 포인터라는 개념이 러스트에만 있는 것은 아닙니다. 스마트 포인터는 C++에서 시작되었고 다른 언어에도 있으며, C++의 unique_ptr, shared_ptr, weak_ptr는 C++ 스마트 포인터에서 다룹니다. 러스트의 표준 라이브러리에는 참조가 제공하는 것 이상의 기능을 주는 여러 스마트 포인터가 정의되어 있습니다. 이번 장에서는 일반적인 개념을 살펴보기 위해 스마트 포인터의 여러 예를 봅니다. 그 가운데 하나인 참조 카운팅(reference counting) 스마트 포인터 타입은, 소유자의 수를 추적해 데이터에 여러 소유자가 있게 해 주고, 소유자가 하나도 남지 않으면 데이터를 정리합니다.
소유권과 대여라는 개념이 있는 러스트에서는 참조와 스마트 포인터 사이에 차이가 하나 더 있습니다. 참조는 데이터를 빌리기만 하지만, 많은 경우 스마트 포인터는 자신이 가리키는 데이터를 소유합니다. 사실 이 교재에서는 이미 스마트 포인터를 몇 가지 만났습니다. 벡터와 값 목록의 Vec<T>와 UTF-8 문자열의 String이 그것입니다. 두 타입은 어떤 메모리를 소유하고 그것을 다룰 수 있게 해 주므로 스마트 포인터입니다. 또 메타데이터와 추가 기능이나 보장도 가집니다. 예를 들어 String은 용량을 메타데이터로 저장하며, 데이터가 언제나 올바른 UTF-8이라는 추가 보장을 가집니다.
스마트 포인터는 대개 구조체로 구현됩니다. 보통의 구조체와 달리 스마트 포인터는 Deref와 Drop 트레이트를 구현합니다. Deref 트레이트는 스마트 포인터 구조체의 인스턴스가 참조처럼 동작하게 해, 참조와 스마트 포인터 모두에 동작하는 코드를 쓸 수 있게 해 줍니다. Drop 트레이트는 스마트 포인터의 인스턴스가 스코프를 벗어날 때 실행할 코드를 바꿀 수 있게 해 줍니다. 이번 장에서는 두 트레이트를 다루고, 스마트 포인터에 왜 중요한지 보여 줍니다.
스마트 포인터 패턴은 러스트에서 자주 쓰이는 일반적인 설계 패턴이므로, 이번 장에서 존재하는 스마트 포인터를 모두 다루지는 않습니다. 많은 라이브러리에 자기만의 스마트 포인터가 있으며, 직접 만들 수도 있습니다. 이번 장에서는 표준 라이브러리에서 가장 흔한 스마트 포인터를 다룹니다.
| 글 | 다루는 것 |
|---|---|
| 이 글 | 힙에 값을 할당하는 Box<T> |
| Deref와 역참조 강제 변환 | 스마트 포인터를 참조처럼 다루게 하는 Deref 트레이트 |
| Drop과 자원 정리 | 값이 스코프를 벗어날 때 코드를 실행하는 Drop 트레이트 |
Rc<T>, 참조 카운트 스마트 포인터 | 여러 소유자를 가능하게 하는 참조 카운팅 타입 Rc<T> |
RefCell<T>와 내부 가변성 패턴 | 대여 규칙을 컴파일 시점 대신 런타임에 적용하는 RefCell<T>와, 그것으로 접근하는 Ref<T>, RefMut<T> |
| 순환 참조와 메모리 누수 | 순환 참조가 메모리를 새게 하는 방식과 그것을 막는 방법 |
또 불변 타입이 안쪽 값을 바꿀 수 있게 해 주는 API를 노출하는 내부 가변성(interior mutability) 패턴도 다룹니다. 그럼 시작합니다.
Box<T>를 사용하여 힙에 데이터 저장하기
가장 간단한 스마트 포인터는 박스(box)이며, 타입은 Box<T>라고 씁니다. 박스는 데이터를 스택이 아니라 힙에 저장하게 해 줍니다. 스택에 남는 것은 힙 데이터를 가리키는 포인터입니다. 스택과 힙의 차이는 소유권이 뭔가요?에서 다루었습니다.
박스는 데이터를 스택 대신 힙에 저장한다는 것 말고는 성능상의 추가 비용이 없습니다. 하지만 추가 기능도 많지 않습니다. 박스는 주로 다음 상황에서 씁니다.
| 상황 | 예 |
|---|---|
| 컴파일 시점에 크기를 알 수 없는 타입이 있는데, 정확한 크기가 필요한 맥락에서 그 타입의 값을 쓰고 싶을 때 | 이 글의 재귀적 타입 |
| 큰 데이터가 있는데, 데이터를 복사하지 않고 소유권을 옮기고 싶을 때 | 큰 데이터를 박스에 넣어 힙에 두면, 소유권을 옮길 때 작은 포인터만 스택에서 복사됩니다. |
| 어떤 값을 소유하고 싶은데, 특정 타입이 아니라 특정 트레이트를 구현한 타입이라는 것만 중요할 때 | 트레이트 객체와 다형성의 트레이트 객체 |
첫 번째 상황은 이 글의 박스로 재귀적 타입 가능하게 하기에서 보여 줍니다. 두 번째 상황에서, 큰 데이터의 소유권을 옮기는 일은 데이터를 스택에서 복사하므로 오래 걸릴 수 있습니다. 이때 성능을 높이려면 큰 데이터를 박스에 넣어 힙에 저장하면 됩니다. 그러면 작은 양의 포인터 데이터만 스택에서 복사되고, 그것이 가리키는 데이터는 힙의 한 곳에 그대로 있습니다. 세 번째 상황은 트레이트 객체(trait object)라고 하며, 트레이트 객체와 다형성에서 다룹니다. 여기서 배운 것을 그 글에서 다시 쓰게 됩니다.
힙에 데이터 저장하기
Box<T>가 힙에 데이터를 저장하는 경우를 이야기하기 전에, 문법과 Box<T> 안에 저장된 값과 상호작용하는 방법을 봅니다. 다음 코드는 박스로 i32 값을 힙에 저장합니다.
fn main() {
let b = Box::new(5);
println!("b = {b}");
}변수 b를 힙에 할당된 값 5를 가리키는 Box의 값으로 정의했습니다. 이 프로그램은 b = 5를 출력합니다. 이 경우 박스 안의 데이터에는 그 데이터가 스택에 있을 때와 비슷한 방식으로 접근할 수 있습니다. 다른 소유된 값처럼, main이 끝날 때 b가 스코프를 벗어나면 박스는 해제됩니다. 해제는 스택에 저장된 박스와 힙에 저장된 그것이 가리키는 데이터 모두에 일어납니다. 아래 그림은 박스의 소유권을 다른 변수로 옮기면 무슨 일이 일어나는지 보여 줍니다.
Box 간접화와 소유권 수명
a Box i32=5→b로 핸들 이동/a 사용 불가→b 정상 drop에서 같은 값 정리와 할당 해제가 일어난다. 깊은 복제나 실제 스택 슬롯 배치를 주장하지 않는다.
RUST · UNIQUE OWNERSHIP & INDIRECTION
Box가 이동해도 힙 값은 같은 할당에 남는다
Box는 값을 유일하게 소유합니다. 핸들 이동, 재귀 타입의 간접화, 정상 drop은 서로 다른 역할이며 아래 예는 각각의 경계를 나눠 보여줍니다.
a → b · 소유자 이동
Box 핸들이 b로 이동하면 a는 더 이상 사용할 수 없습니다.
힙 값 5 · 그대로
b가 같은 i32 할당을 유일하게 소유하며 값을 깊게 복제하지 않습니다.
본문의 Box::new(5)에 이동 한 줄을 더한 예
let a = Box::new(5);
let b = a;
assert_eq!(*b, 5);지역 변수와 비영 크기 i32의 개념 모델입니다. Box 핸들이 반드시 스택에만 있어야 한다는 뜻은 아닙니다. 할당·접근 비용이 사라지거나 pinning이 보장된다는 뜻도 아닙니다.
본문의 재귀 List · 크기 계산을 끊기
enum List {
Cons(i32, Box<List>),
Nil,
}Cons 안에 List를 직접 넣으면 크기 계산이 반복되어 E0072입니다. 고정 크기 Box<List> 핸들로 간접화하면 List 전체의 크기를 유한하게 계산합니다. 실제 크기는 정렬·패딩·enum 판별 정보도 반영합니다.
정상 drop · 값 정리 뒤 할당 해제
b가 정상적으로 drop되면 i32를 정리하고 해당 할당을 해제합니다. Box<dyn Trait>/Box<[T]> 같은 DST 소유 핸들은 메타데이터를 포함할 수 있습니다. forget·ManuallyDrop·비정상 프로세스 종료에서는 일반 정리 경로가 실행되지 않을 수 있습니다.
Box<T>의 T는 ?Sized일 수 있으므로 모든 Box를 얇은 포인터라고 부를 수 없습니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 세 노드와 두 간선이다. a의 Box가 b로 이동하며 이후 a를 사용할 수 없다. b가 같은 힙 값 5를 소유한다. 첫 간선은 핸들 소유권 이동, 둘째는 소유 포인터 관계이며 두 번의 할당이나 힙 값 복제를 뜻하지 않는다. stack/heap 표현은 본문의 단순 지역 변수 모델이며 Box 핸들이 항상 stack에만 있다는 주장은 아니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Box 소유 Box를 보유한 a가 할당된 i32 값 5를 소유한다. |
|
|
| 핸들 이동 let b=a로 같은 할당의 소유자가 바뀐다. |
|
|
| 최종 drop b를 정상 정리해 값과 할당을 해제한다. |
|
|
Box 간접화와 소유권 수명
a Box i32=5→b로 핸들 이동/a 사용 불가→b 정상 drop에서 같은 값 정리와 할당 해제가 일어난다. 깊은 복제나 실제 스택 슬롯 배치를 주장하지 않는다.
RUST · UNIQUE OWNERSHIP & INDIRECTION
Box가 이동해도 힙 값은 같은 할당에 남는다
Box는 값을 유일하게 소유합니다. 핸들 이동, 재귀 타입의 간접화, 정상 drop은 서로 다른 역할이며 아래 예는 각각의 경계를 나눠 보여줍니다.
a → b · 소유자 이동
Box 핸들이 b로 이동하면 a는 더 이상 사용할 수 없습니다.
힙 값 5 · 그대로
b가 같은 i32 할당을 유일하게 소유하며 값을 깊게 복제하지 않습니다.
본문의 Box::new(5)에 이동 한 줄을 더한 예
let a = Box::new(5);
let b = a;
assert_eq!(*b, 5);지역 변수와 비영 크기 i32의 개념 모델입니다. Box 핸들이 반드시 스택에만 있어야 한다는 뜻은 아닙니다. 할당·접근 비용이 사라지거나 pinning이 보장된다는 뜻도 아닙니다.
본문의 재귀 List · 크기 계산을 끊기
enum List {
Cons(i32, Box<List>),
Nil,
}Cons 안에 List를 직접 넣으면 크기 계산이 반복되어 E0072입니다. 고정 크기 Box<List> 핸들로 간접화하면 List 전체의 크기를 유한하게 계산합니다. 실제 크기는 정렬·패딩·enum 판별 정보도 반영합니다.
정상 drop · 값 정리 뒤 할당 해제
b가 정상적으로 drop되면 i32를 정리하고 해당 할당을 해제합니다. Box<dyn Trait>/Box<[T]> 같은 DST 소유 핸들은 메타데이터를 포함할 수 있습니다. forget·ManuallyDrop·비정상 프로세스 종료에서는 일반 정리 경로가 실행되지 않을 수 있습니다.
Box<T>의 T는 ?Sized일 수 있으므로 모든 Box를 얇은 포인터라고 부를 수 없습니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 세 노드와 두 간선이다. a의 Box가 b로 이동하며 이후 a를 사용할 수 없다. b가 같은 힙 값 5를 소유한다. 첫 간선은 핸들 소유권 이동, 둘째는 소유 포인터 관계이며 두 번의 할당이나 힙 값 복제를 뜻하지 않는다. stack/heap 표현은 본문의 단순 지역 변수 모델이며 Box 핸들이 항상 stack에만 있다는 주장은 아니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Box 소유 Box를 보유한 a가 할당된 i32 값 5를 소유한다. |
|
|
| 핸들 이동 let b=a로 같은 할당의 소유자가 바뀐다. |
|
|
| 최종 drop b를 정상 정리해 값과 할당을 해제한다. |
|
|
그림은 이 글의 Box::new(5)에 let b = a; 한 줄을 더한 예입니다. a가 가진 박스를 b로 옮기면 그 뒤로 a는 쓸 수 없고, b가 같은 힙 할당의 값 5를 유일하게 소유하므로 assert_eq!(*b, 5);가 성립합니다. 첫 화살표는 박스의 소유권이 옮겨 가는 것이고 둘째 화살표는 박스가 힙 값을 소유하는 관계이며, 할당을 두 번 하거나 힙 값을 복제한다는 뜻이 아닙니다. b가 정상적으로 버려지면 i32를 정리하고 그 할당을 해제합니다. 스택과 힙으로 나눈 그림은 단순한 지역 변수의 개념 모델이며, 박스 자체가 언제나 스택에만 있어야 한다는 뜻은 아닙니다. Box<T>의 T는 크기가 정해지지 않은 타입일 수도 있어서, Box<dyn Trait>나 Box<[T]>처럼 메타데이터를 함께 가지는 박스도 있습니다. std::mem::forget이나 ManuallyDrop을 쓰거나 프로세스가 비정상적으로 끝나면 이 정리 과정이 실행되지 않을 수 있습니다.
값 하나를 힙에 두는 것은 그다지 쓸모가 없으므로, 이렇게 박스를 단독으로 쓸 일은 많지 않습니다. 대부분의 상황에서는 i32 하나 같은 값은 기본으로 저장되는 스택에 두는 것이 더 알맞습니다. 박스가 없었다면 정의할 수 없었을 타입을 박스로 정의하는 경우를 봅니다.
박스로 재귀적 타입 가능하게 하기
재귀적 타입(recursive type)의 값은 자기 안에 같은 타입의 다른 값을 가질 수 있습니다. 러스트는 컴파일 시점에 타입이 얼마나 많은 공간을 차지하는지 알아야 하므로 재귀적 타입은 문제가 됩니다. 재귀적 타입의 값은 이론적으로 끝없이 중첩될 수 있으므로, 러스트는 그 값에 얼마나 많은 공간이 필요한지 알 수 없습니다. 박스는 크기가 정해져 있으므로, 재귀적 타입의 정의에 박스를 넣어 재귀적 타입을 만들 수 있습니다.
재귀적 타입의 예로 콘스 리스트(cons list)를 봅니다. 콘스 리스트는 함수형 프로그래밍 언어에서 흔히 보는 데이터 타입입니다. 여기서 정의할 콘스 리스트 타입은 재귀를 빼면 단순하므로, 이 예에서 다루는 개념은 재귀적 타입이 들어가는 더 복잡한 상황에서도 쓸모가 있습니다.
콘스 리스트에 대한 더 많은 정보
콘스 리스트는 Lisp 프로그래밍 언어와 그 방언들에서 온 자료 구조로, 중첩된 쌍으로 이루어진 연결 리스트입니다. 이름은 Lisp의 cons 함수(construct function의 줄임말)에서 왔습니다. cons는 두 인수로 새 쌍을 만듭니다. 값 하나와 다른 쌍으로 이루어진 쌍에 cons를 부르면, 재귀적인 쌍으로 이루어진 콘스 리스트를 만들 수 있습니다.
예를 들어 다음은 리스트 1, 2, 3을 담은 콘스 리스트를 의사 코드로 나타낸 것이며, 각 쌍을 괄호로 묶었습니다.
(1, (2, (3, Nil)))콘스 리스트의 각 항목에는 두 원소가 들어 있습니다. 현재 항목의 값과 다음 항목입니다. 리스트의 마지막 항목에는 다음 항목 없이 Nil이라는 값만 들어 있습니다. 콘스 리스트는 cons 함수를 재귀적으로 불러 만듭니다. 재귀의 기본 사례를 나타내는 이름은 Nil입니다. 이것은 열거형 정의에서 이야기한, 유효하지 않거나 없는 값이라는 뜻의 null이나 nil과는 다릅니다.
콘스 리스트는 러스트에서 흔히 쓰는 자료 구조가 아닙니다. 러스트에서 항목의 목록이 필요할 때는 대부분 Vec<T>가 더 나은 선택입니다. 그 밖의 더 복잡한 재귀적 데이터 타입은 여러 상황에서 쓸모가 있지만, 이 글에서는 콘스 리스트로 시작해 박스가 어떻게 재귀적 데이터 타입을 큰 어려움 없이 정의하게 해 주는지 살펴봅니다.
다음 코드는 콘스 리스트를 열거형으로 정의한 것입니다. 이 코드는 아직 컴파일되지 않습니다. List 타입의 크기가 정해지지 않기 때문이며, 곧 그 까닭을 보여 줍니다.
enum List {
Cons(i32, List),
Nil,
}이 예제에서는 i32 값만 담는 콘스 리스트를 구현합니다. 제네릭 데이터 타입에서 이야기한 제네릭을 쓰면 아무 타입의 값이나 저장하는 콘스 리스트 타입을 정의할 수도 있습니다.
List 타입으로 리스트 1, 2, 3을 저장하면 다음과 같습니다.
enum List {
Cons(i32, List),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Cons(2, Cons(3, Nil)));
}첫 Cons 값은 1과 다른 List 값을 가집니다. 이 List 값은 2와 또 다른 List 값을 가진 Cons 값입니다. 이 List 값은 3과 마지막 List 값인 Nil을 가진 또 하나의 Cons 값이며, Nil은 리스트의 끝을 알리는 재귀적이지 않은 배리언트입니다. 이 코드를 컴파일하려고 하면 다음 오류가 납니다.
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
2 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +
For more information about this error, try `rustc --explain E0072`.오류는 이 타입의 크기가 무한하다고 알려 줍니다. 까닭은 List를 재귀적인 배리언트로 정의했기 때문입니다. 이 배리언트는 자기 자신의 다른 값을 직접 담습니다. 그 결과 러스트는 List 값을 저장하는 데 얼마나 많은 공간이 필요한지 알아낼 수 없습니다. 왜 이런 오류가 나는지 나눠서 봅니다. 먼저 러스트가 재귀적이지 않은 타입의 값을 저장하는 데 필요한 공간을 어떻게 정하는지 봅니다.
비재귀적 타입의 크기 계산하기
열거형 정의에서 열거형 정의를 이야기하며 정의한 Message 열거형을 떠올려 보세요. 재귀가 없는 이 열거형의 크기를 러스트가 어떻게 정하는지 먼저 보면, 같은 방법이 재귀적 타입에서 왜 막히는지 알 수 있습니다.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}Message 값에 얼마나 많은 공간을 할당할지 정하기 위해, 러스트는 각 배리언트를 살펴 어느 배리언트에 가장 많은 공간이 필요한지 봅니다. 러스트는 Message::Quit에 공간이 필요 없고, Message::Move에는 i32 값 두 개를 저장할 만큼의 공간이 필요하다는 식으로 알아냅니다. 한 번에 하나의 배리언트만 쓰이므로, Message 값에 필요한 공간은 가장 큰 배리언트를 저장하는 데 필요한 공간입니다. 실제 크기에는 정렬과 패딩, 어느 배리언트인지 구분하는 정보도 반영됩니다.
이것과 대조해, 러스트가 앞의 List 열거형 같은 재귀적 타입에 필요한 공간을 정하려 할 때 무슨 일이 일어나는지 봅니다. 컴파일러는 Cons 배리언트부터 보는데, 이 배리언트는 i32 타입의 값과 List 타입의 값을 가집니다. 그러므로 Cons에는 i32의 크기에 List의 크기를 더한 만큼의 공간이 필요합니다. List 타입에 얼마나 많은 메모리가 필요한지 알아내기 위해 컴파일러는 배리언트들을 보는데, Cons 배리언트부터 봅니다. Cons 배리언트는 i32 타입의 값과 List 타입의 값을 가지며, 이 과정은 아래 그림처럼 끝없이 이어집니다.
Box<T>를 이용하여 알려진 크기를 가진 재귀적 타입 만들기
러스트는 재귀적으로 정의된 타입에 얼마나 많은 공간을 할당해야 하는지 알아낼 수 없으므로, 컴파일러는 앞의 오류와 함께 다음과 같은 쓸모 있는 제안을 줍니다. 도움말의 + 표시는 원래의 List를 Box<List>로 감싸라는 뜻으로, Box<와 >가 더해질 자리를 가리킵니다.
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +이 제안에서 간접(indirection)이란, 값을 직접 저장하는 대신 자료 구조를 바꿔 값을 가리키는 포인터를 저장해, 값을 간접적으로 저장해야 한다는 뜻입니다. Box<T>는 포인터이므로, 러스트는 Box<T>에 얼마나 많은 공간이 필요한지 언제나 압니다. 포인터의 크기는 그것이 가리키는 데이터의 양에 따라 바뀌지 않습니다. 그러므로 Cons 배리언트 안에 다른 List 값을 직접 넣는 대신 Box<T>를 넣을 수 있습니다. Box<T>는 Cons 배리언트 안이 아니라 힙에 있는 다음 List 값을 가리킵니다. 개념적으로는 여전히 리스트를 담은 리스트로 만든 리스트이지만, 이제 이 구현은 항목을 다른 항목 안에 넣는 것이 아니라 서로 옆에 두는 것에 더 가깝습니다.
앞의 List 열거형의 정의와 사용을 다음 코드로 바꾸면 컴파일됩니다.
enum List {
Cons(i32, Box<List>),
Nil,
}
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}Cons 배리언트에는 i32의 크기에 박스의 포인터 데이터를 저장할 공간을 더한 만큼이 필요합니다. Nil 배리언트는 값을 저장하지 않으므로, 스택에서 Cons 배리언트보다 적은 공간이 필요합니다. 이제 어떤 List 값이든 i32의 크기에 박스의 포인터 데이터의 크기를 더한 만큼만 차지한다는 것을 압니다. 박스를 써서 끝없는 재귀의 고리를 끊었으므로, 컴파일러는 List 값을 저장하는 데 필요한 크기를 알아낼 수 있습니다. 아래 그림은 이제 Cons 배리언트가 어떻게 생겼는지 보여 줍니다.
박스는 간접과 힙 할당만 제공합니다. 앞으로 다른 스마트 포인터 타입에서 보게 될 다른 특별한 기능은 없습니다. 그런 특별한 기능 때문에 생기는 성능상의 추가 비용도 없으므로, 콘스 리스트처럼 간접만 필요한 경우에 쓸모가 있습니다. 박스의 쓰임새는 트레이트 객체와 다형성에서 더 봅니다.
Box<T> 타입은 Deref 트레이트를 구현하므로 스마트 포인터입니다. Deref 트레이트 덕분에 Box<T> 값을 참조처럼 다룰 수 있습니다. Box<T> 값이 스코프를 벗어나면, 박스가 가리키는 힙 데이터도 Drop 트레이트의 구현 덕분에 정리됩니다. 이 두 트레이트는 이번 장의 나머지 부분에서 다룰 다른 스마트 포인터 타입이 제공하는 기능에 더욱 중요합니다. 두 트레이트를 더 자세히 봅니다.
더 깊이: Box<T>만 가진 특별한 능력
Box<T>는 표준 라이브러리의 타입이지만 언어와 단단히 묶여 있습니다. Reference의 특별한 타입과 트레이트 절은 사용자가 정의한 타입에는 아직 허용되지 않는 Box<T>의 기능 세 가지를 듭니다. 첫째, Box<T>에 쓴 역참조 연산자는 값을 옮겨 낼 수 있는 자리를 만듭니다. 그래서 let s = *b;처럼 박스 안의 String을 꺼내 옮길 수 있으며, 이 때문에 Box<T>의 * 연산자와 소멸자는 언어에 내장되어 있습니다. 같은 일을 Rc<T>나 직접 만든 스마트 포인터에 하면, Copy가 아닌 값을 빌린 자리에서 옮길 수 없다는 오류가 납니다.
둘째, 메서드는 self: Box<Self>처럼 Box<Self>를 받는 쪽(receiver)으로 받을 수 있으며, 객체 지향 디자인 패턴에서 상태를 바꾸는 메서드가 이 형태를 씁니다. 셋째, 고아 규칙은 보통 바깥 크레이트의 제네릭 타입에 바깥 트레이트를 구현하지 못하게 막지만, T를 정의한 크레이트라면 Box<T>에는 트레이트를 구현할 수 있습니다. 이 성질들 덕분에 Box<T>는 값 하나를 힙으로 옮기는 것 말고는 그 값을 직접 가진 것과 거의 같은 방식으로 다룰 수 있습니다.
정리
- 스마트 포인터는 포인터처럼 동작하면서 추가 메타데이터와 기능을 가진 자료 구조로, 대개 가리키는 데이터를 소유하며
Deref와Drop을 구현합니다.String과Vec<T>도 스마트 포인터입니다. Box<T>는 값을 힙에 두고 그것을 가리키는 포인터를 가지며, 힙 할당 말고는 추가 비용이 없습니다.- 박스는 크기를 알 수 없는 타입을 정해진 크기가 필요한 곳에 쓸 때, 큰 데이터를 복사하지 않고 소유권을 옮길 때, 트레이트 객체를 쓸 때 씁니다.
- 박스를 옮기면 소유자만 바뀌고 힙 값은 같은 할당에 남으며, 소유자가 버려지면 값과 할당이 함께 정리됩니다.
Cons(i32, List)처럼 자기 자신을 직접 담는 재귀적 타입은 크기가 무한해 E0072 오류가 나며,Box<List>로 간접을 넣으면 크기를 정할 수 있습니다.
연습 문제
1. 무엇이 컴파일될까
다음 두 정의 가운데 어느 것이 컴파일되나요? 그 까닭은 무엇인가요?
struct TreeA {
value: i32,
children: Vec<TreeA>,
}
struct TreeB {
value: i32,
left: Option<TreeB>,
}해설 보기
TreeA는 컴파일됩니다. Vec<TreeA>는 원소를 힙에 저장하고 자신은 포인터와 길이, 용량만 가지므로 크기가 정해져 있기 때문입니다. TreeB는 Option<TreeB>가 TreeB를 직접 담으므로 크기가 무한해 E0072 오류가 납니다. left: Option<Box<TreeB>>로 바꾸면 간접이 들어가 컴파일됩니다.
2. 옮긴 뒤에는
다음 코드에서 마지막 줄의 주석을 지우면 어떻게 되나요? b가 가리키는 값은 새로 할당된 것인가요?
let a = Box::new(String::from("hello"));
let b = a;
println!("{b}");
// println!("{a}");해설 보기
let b = a;에서 박스의 소유권이 b로 옮겨 갔으므로, a를 쓰면 이동한 값을 빌리려 했다는 E0382 오류로 컴파일되지 않습니다. 옮겨 간 것은 박스 자체이며, 힙에 있는 String은 새로 할당되거나 복제되지 않고 같은 할당에 그대로 있습니다.
다음 글 Deref와 역참조 강제 변환에서는 Deref 트레이트로 스마트 포인터를 보통의 참조처럼 다루는 방법을 살펴봅니다.