Box\<T\>로 힙에 데이터 올려두기
Box<T>로 힙에 데이터 올려두기
가장 단순한 스마트 포인터가 바로 박스(box)예요. 타입은 Box<T>라고 써요. 박스를 쓰면 데이터를 스택 대신 힙(heap)에 저장할 수 있어요. 스택에 남는 것은 힙 데이터를 가리키는 포인터뿐이죠. 스택과 힙의 차이에 대해 다시 보고 싶으면 4장을 돌아봐요.
박스는 데이터를 스택이 아니라 힙에 저장한다는 점 외에는 성능 오버헤드가 없어요. 그렇다고 딱히 뭔가 특별한 기능이 많은 것도 아니고요. 박스는 보통 이런 상황에서 써요.
- 컴파일 시점에 크기를 알 수 없는 타입이 있는데, 그 값을 정확한 크기가 필요한 문맥에서 쓰고 싶을 때
- 데이터가 아주 큰데 소유권만 옮기고 싶고, 옮길 때 복사는 일어나지 않게 하고 싶을 때
- 어떤 값을 소유하긴 하는데, 그 값이 특정 타입이 아니라 특정 트레이트(trait)를 구현한다는 사실만 중요할 때
첫 번째 상황은 「Box로 재귀 타입 만들기」에서 직접 해 볼게요. 두 번째 경우는 큰 데이터의 소유권을 옮길 때 스택에서 데이터가 계속 복사되면서 오래 걸릴 수 있어요. 이 상황에서 성능을 살리려면 큰 데이터를 박스에 담아 힙에 저장해 두면 돼요. 그러면 스택에서는 포인터라는 작은 데이터만 복사되고, 포인터가 가리키는 데이터는 힙의 한 자리에 그대로 머물러요. 세 번째 경우를 우리는 트레이트 객체(trait object)라고 부르는데, 18장의 「트레이트 객체로 공통 동작 추상화하기」에서 자세히 다뤄요. 이번에 배운 걸 그 단락에서 또 활용하게 되니 반가운 일이죠!
본문
데이터를 힙에 저장하기
Box<T>를 힙 저장소에 쓸 일을 다루기 전에, 문법과 Box<T>에 담긴 값에 접근하는 방법부터 볼게요. Listing 15-1은 박스를 써서 i32 값을 힙에 저장하는 모습이에요.
Filename: src/main.rs
fn main() {
let b = Box::new(5);
println!("b = {b}");
}
Listing 15-1: 박스를 사용해 힙에 i32 값 저장하기
변수 b에는 힙에 할당된 값 5를 가리키는 Box를 담아요. 이 프로그램을 실행하면 b = 5라고 출력돼요. 이 경우 데이터가 힙에 있든 스택에 있든, 박스 안의 데이터에는 스택에 있을 때와 똑같이 접근할 수 있어요.
다른 소유 값과 마찬가지로, main의 끝에서 b처럼 박스가 스코프를 벗어나면 해제(deallocate)돼요. 해제는 스택에 저장된 박스 자신과, 그 박스가 가리키는 힙 데이터 둘 다에 일어나요.
값 하나를 힙에 올려두는 일은 그리 유용하지 않아서, 박스를 이런 방식으로 혼자 쓰는 경우는 흔치 않아요. i32 같은 값 하나는 기본적으로 저장되는 스택에 두는 편이 대부분의 상황에서 더 적절해요. 이제 박스가 없다면 정의조차 할 수 없었던 타입을 박스 덕분에 정의할 수 있는 경우를 살펴볼게요.
Box로 재귀 타입 만들기
재귀 타입(recursive type)의 값은 자기 자신과 같은 타입의 값을 일부로 가질 수 있어요. 그런데 재귀 타입은 문제가 있어요. Rust는 컴파일 시점에 타입이 차지하는 공간이 얼마인지 알아야 하거든요. 그런데 재귀 타입 값의 중첩은 이론적으로 무한히 이어질 수 있어서, 값에 필요한 공간이 얼마인지 Rust는 알 수 없어요. 박스는 크기가 정해져 있으니, 재귀 타입 정의에 박스를 끼워 넣으면 재귀 타입을 쓸 수 있게 돼요.
재귀 타입의 예시로 cons list를 살펴볼게요. 함수형 프로그래밍 언어에서 흔히 볼 수 있는 자료형이에요. 우리가 정의할 cons list 타입은 재귀라는 점만 빼면 아주 단순해서, 이 예시에서 다루는 개념을 나중에 재귀 타입이 얽힌 더 복잡한 상황에서도 그대로 쓸 수 있어요.
cons list 이해하기
cons list는 Lisp와 그 계열 언어에서 나온 자료 구조로, 중첩된 쌍(pair)으로 이루어져 있어요. Lisp 버전의 연결 리스트(linked list)라고 보면 돼요. 이름은 Lisp의 cons 함수(생성 함수, construct function의 줄임말)에서 왔는데, cons는 두 인자로 새 쌍을 만들어 내는 함수예요. 값이 든 쌍에 다른 쌍을 cons로 계속 붙여 나가면, 재귀적인 쌍으로 이루어진 cons list를 만들 수 있어요.
예를 들어 1, 2, 3을 담은 cons list를 의사 코드로 나타내면 각 쌍을 괄호로 묶어서 이렇게 돼요.
(1, (2, (3, Nil)))
cons list의 각 항목에는 두 가지가 들어 있어요. 바로 현재 항목의 값과 다음 항목이죠. 리스트의 마지막 항목에는 다음 항목 없이 값만 들어 있는데, 그 값을 Nil이라고 불러요. cons list는 cons 함수를 재귀적으로 호출해서 만들어져요. 재귀의 기저 사례(base case)를 나타내는 이름으로 관례상 Nil을 써요. 참고로 6장에서 다룬 "null"이나 "nil" 개념과는 달라요. 그쪽은 유효하지 않거나 없는 값을 뜻하니까 헷갈리지 마세요.
cons list는 Rust에서 자주 쓰이는 자료 구조는 아니에요. 항목들의 리스트가 필요할 때는 대부분 Vec<T>가 더 나은 선택이죠. 더 복잡한 재귀 데이터 타입도 여러 상황에서 유용하지만, 이 장에서 cons list부터 시작하면 박스로 재귀 데이터 타입을 정의하는 법을 별다른 방해 없이 탐구할 수 있어요.
Listing 15-2는 cons list를 위한 enum 정의예요. List 타입의 크기를 아직 모르기 때문에 이 코드는 아직 컴파일되지 않는데, 그 이유를 곧 확인할 수 있어요.
Filename: src/main.rs
enum List {
Cons(i32, List),
Nil,
}
fn main() {}
Listing 15-2: i32 값들의 cons list 자료 구조를 나타내는 enum을 정의하려는 첫 시도
참고: 이 예시에서는
i32값만 담는 cons list를 구현하고 있어요. 10장에서 다룬 제네릭을 쓰면 어떤 타입의 값이든 저장할 수 있는 cons list 타입을 정의할 수도 있었지만요.
List 타입으로 1, 2, 3 리스트를 저장하는 코드는 Listing 15-3과 같아요.
Filename: src/main.rs
enum List {
Cons(i32, List),
Nil,
}
// --snip--
use crate::List::{Cons, Nil};
fn main() {
let list = Cons(1, Cons(2, Cons(3, Nil)));
}
Listing 15-3: List enum을 사용해 1, 2, 3 리스트 저장하기
첫 번째 Cons 값은 1과 또 하나의 List 값을 담아요. 그 List 값은 2와 또 다른 List 값을 담는 또 하나의 Cons 값이에요. 그 List 값은 3과 리스트의 끝을 알리는 비재귀 변형인 Nil을 담는 마지막 Cons 값이 되죠.
Listing 15-3의 코드를 컴파일해 보면 Listing 15-4에 보이는 오류가 나와요.
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
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>),
| ++++ +
error[E0391]: cycle detected when computing when `List` needs drop
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
|
= note: ...which immediately requires computing when `List` needs drop again
= note: cycle used when computing whether `List` needs drop
= note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information
Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
Listing 15-4: 재귀 enum을 정의하려 할 때 나오는 오류
오류는 이 타입이 "무한한 크기를 가진다(has infinite size)"고 알려줘요. 이유는 List를 재귀적인 변형으로 정의했기 때문이에요. 즉 자기 자신의 다른 값을 직접 담고 있어요. 그 결과 Rust는 List 값을 저장하는 데 필요한 공간이 얼마인지 알아낼 수 없어요. 오류가 왜 나는지 하나씩 따져볼게요. 먼저 비재귀 타입의 값을 저장하는 데 필요한 공간을 Rust가 어떻게 정하는지 보면 돼요.
비재귀 타입의 크기 계산하기
6장에서 enum 정의를 다룰 때 6-2의 Listing에서 정의했던 Message enum을 떠올려 볼게요.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {}
Message 값에 공간이 얼마나 필요한지 정하려고 Rust는 각 변형을 훑어보면서 어떤 변형이 가장 많은 공간을 차지하는지 확인해요. Rust는 Message::Quit은 공간이 전혀 필요 없고, Message::Move는 i32 값 두 개를 저장할 만큼 필요하다는 식으로 보는 거죠. 변형 중 하나만 쓰이기 때문에 Message 값이 필요로 하는 최대 공간은 변형들 중 가장 큰 것을 저장하는 데 필요한 공간이 돼요.
이번엔 그 반대로, Listing 15-2의 List enum 같은 재귀 타입이 얼마만큼의 공간을 필요로 하는지 Rust가 알아내려 할 때 무슨 일이 일어나는지 비교해 볼게요. 컴파일러는 일단 i32 타입의 값과 List 타입의 값을 담는 Cons 변형부터 봐요. 그래서 Cons는 i32 크기에 List 크기를 더한 만큼의 공간이 필요해요. List 타입이 메모리를 얼마나 차지할지 알아내기 위해 컴파일러는 다시 변형들을 보는데, 또 Cons부터 시작해요. Cons 변형은 i32 값과 List 값을 담으니까, 이 과정은 그림 15-1처럼 무한히 계속돼요.
그림 15-1: 무한한 Cons 변형들로 이루어진 무한한 List
크기가 정해진 재귀 타입 만들기
재귀적으로 정의된 타입에 공간을 얼마나 할당해야 하는지 Rust가 알아낼 수 없기 때문에, 컴파일러는 이렇게 도움이 되는 제안을 곁들여 오류를 내요.
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +
이 제안에서 말하는 indirection(간접화)은 값 자체를 직접 저장하는 대신, 값에 대한 포인터를 저장해 값에 간접적으로 접근하도록 자료 구조를 바꾸라는 뜻이에요.
Box<T>는 포인터이기 때문에 Rust는 Box<T>에 필요한 공간이 얼마인지 항상 알아요. 포인터의 크기는 그 포인터가 가리키는 데이터의 양에 따라 달라지지 않거든요. 따라서 Cons 변형 안에 다른 List 값을 직접 넣는 대신 Box<T>를 넣을 수 있어요. 그 Box<T>는 Cons 변형 안이 아니라 힙에 있을 다음 List 값을 가리키게 돼요. 개념적으로는 여전히 리스트가 리스트를 담는 방식으로 만들어진 리스트지만, 구현은 이제 항목들을 서로 안에 넣는 대신 서로 나란히 두는 방식에 가까워져요.
Listing 15-2의 List enum 정의와 Listing 15-3의 List 사용을 Listing 15-5의 코드로 바꾸면 컴파일돼요.
Filename: src/main.rs
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))))));
}
Listing 15-5: 크기를 알 수 있도록 Box<T>를 사용하는 List의 정의
Cons 변형에는 i32 크기와 박스의 포인터 데이터를 저장할 공간이 필요해요. Nil 변형은 값을 저장하지 않으므로 Cons 변형보다 스택 공간을 덜 차지해요. 이제 어떤 List 값이든 i32 크기에 박스 포인터 데이터 크기를 더한 만큼을 차지한다는 걸 알 수 있어요. 박스를 씀으로써 무한한 재귀 사슬을 끊었기 때문에, 컴파일러는 List 값을 저장하는 데 필요한 크기를 알아낼 수 있게 된 거예요. 그림 15-2는 지금의 Cons 변형이 어떤 모습인지 보여줘요.
그림 15-2: Cons가 Box를 담기 때문에 무한한 크기가 아닌 List
박스는 이렇게 간접화(indirection)와 힙 할당만 제공할 뿐, 다른 스마트 포인터 타입에서 볼 그런 특별한 기능은 없어요. 그 특별한 기능들이 빚어내는 성능 오버헤드도 없기 때문에, cons list처럼 간접화 하나만 필요한 경우에는 박스가 딱이에요. 박스의 더 많은 쓰임새는 18장에서 다룰게요.
이제 본문도 끝났으니 박스가 쓰는 두 트레이트를 잠깐 짚고 넘어갈게요. Box<T> 타입이 스마트 포인터인 이유는 Deref 트레이트를 구현해서, Box<T> 값을 참조(reference)처럼 다룰 수 있게 해 주기 때문이에요. 그리고 Box<T> 값이 스코프를 벗어날 때는 Drop 트레이트 구현 덕분에 그 박스가 가리키는 힙 데이터도 함께 정리돼요. 이 두 트레이트는 이 장의 나머지에서 다룰 다른 스마트 포인터 타입들의 기능에서 훨씬 더 중요한 역할을 해요. 이제 이 두 트레이트를 조금 더 자세히 살펴볼게요.
더 알아보기
- Box<T>로 힙에 데이터 올려두기 (원문) — 이번 주제의 공식 문서 페이지예요.
- 트레이트 객체로 공통 동작 추상화하기 — 박스의 세 번째 쓰임새인 트레이트 객체를 18장에서 다뤄요.
- The Rust Programming Language (공식 서적) — 15장 전체에서 스마트 포인터(
Box<T>,Rc<T>,RefCell<T>등)를 이어서 배울 수 있어요.