Box<T>는 힙에 있는 데이터를 가리키고 알려진 크기를 갖습니다

가장 직관적인 스마트 포인터는 박스 (box) 인데, 이 타입은 Box<T> 라고 쓰입니다. 박스는 여러분이 데이터를 스택이 아니라 힙에 저장할 수 있도록 해줍니다. 스택에 남는 것은 힙 데이터를 가리키는 포인터입니다. 스택과 힙의 차이를 상기하려면 4장을 참조하세요.

박스는 스택 대신 힙에 데이터를 저장한다는 점 외에는, 성능적인 오버헤드가 없습니다. 하지만 여러 가지의 추가 기능 또한 가지고 있지 않습니다. 여러분은 이를 아래와 같은 상황에서 가장 자주 쓰게 될 것입니다:

  • 컴파일 타임에 크기를 알 수 없는 타입을 갖고 있고, 정확한 사이즈를 알 필요가 있는 맥락 안에서 해당 타입의 값을 이용하고 싶을 때
  • 커다란 데이터를 가지고 있고 소유권을 옮기고 싶지만 그렇게 했을 때 데이터가 복사되지 않을 것이라고 보장하기를 원할 때
  • 어떤 값을 소유하고 이 값의 구체화된 타입을 알고 있기보다는 특정 트레잇을 구현한 타입이라는 점만 신경 쓰고 싶을 때

이 장에서는 첫 번째 상황을 보여줄 것입니다. 그러나 보여주기 전에, 나머지 두 상황에 대해 약간 더 자세히 말하겠습니다: 두 번째 경우, 방대한 양의 데이터의 소유권 옮기기는 긴 시간이 소요될 수 있는데 이는 그 데이터가 스택 상에서 복사되기 때문입니다. 이러한 상황에서 성능을 향상하기 위해서, 박스 안의 힙에 그 방대한 양의 데이터를 저장할 수 있습니다. 그러면, 작은 양의 포인터 데이터만 스택 상에서 복사되고, 데이터는 힙 상에서 한 곳에 머물게 됩니다. 세 번째 경우는 트레잇 객체 (trait object) 라고 알려진 것이고, 17장이 이 주제만으로 전체를 쏟아부었습니다. 그러니 여러분이 여기서 배운 것을 17장에서 다시 적용하게 될 것입니다!

Box<T>을 사용하여 힙에 데이터를 저장하기

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 값을 힙에 저장하기

5라는 값을 가리키는 Box의 값을 갖는 변수 b를 선언했는데, 여기서 5는 힙에 할당됩니다. 이 프로그램은 b = 5를 출력할 것입니다; 이 경우, 우리는 마치 이 데이터가 스택에 있었던 것과 유사한 방식으로 박스 내의 데이터에 접근할 수 있습니다. 다른 어떤 소유한 값과 마찬가지로, bmain의 끝에 도달하는 것처럼 어떤 박스가 스코프를 벗어날 때, 할당은 해제될 것입니다. 할당 해제는 (스택에 저장된) 박스와 이것이 가리키고 있는 (힙에 저장된) 데이터 모두에게 일어납니다.

단일 값을 힙에 집어넣는 것은 그다지 유용하지는 않으므로, 이 방식처럼 박스를 이용하는 것은 자주 쓰지 않을 것입니다. 단일한 i32 같은 값을 스택에 갖는 것은, 스택이 해당 값이 기본적으로 저장되는 곳이기도 하고, 대부분의 경우에서 더 적절합니다. 만일 우리가 박스를 쓰지 않는다면 허용되지 않았을 타입을 정의하도록 해주는 경우를 살펴봅시다.

박스는 재귀적 타입을 가능하게 합니다

컴파일 타임에서, 러스트는 어떤 타입이 얼마나 많은 공간을 차지하는지를 알 필요가 있습니다. 컴파일 타임에는 크기를 알 수 없는 한 가지 타입이 바로 재귀적 타입 (recursive type) 인데, 이는 어떤 값이 그 일부로서 동일한 타입의 다른 값을 가질 수 있는 것을 말합니다. 이러한 값의 내포가 이론적으로는 무한하게 계속될 수 있으므로, 러스트는 재귀적 타입의 값이 얼마큼의 공간을 필요로 하는지 알지 못합니다. 하지만, 박스는 알려진 크기를 갖고 있으므로, 재귀적 타입 정의 내에 박스를 넣음으로써 이를 쓸 수 있습니다.

재귀적 타입의 예제로서, 함수형 프로그래밍 언어에서 일반적인 데이터 타입인 cons list를 탐험해 봅시다. 우리가 정의할 cons list 타입은 재귀를 제외하면 직관적입니다; 그러므로, 우리가 작업할 예제에서의 개념은 여러분이 재귀적 타입을 포함하는 더 복잡한 어떠한 경우에 처하더라도 유용할 것입니다.

Cons List에 대한 더 많은 정보

cons list는 Lisp 프로그래밍 언어 및 그의 파생 언어들로부터 유래된 데이터 구조입니다. Lisp에서, (“생성 함수 (construct function)”의 줄임말인) cons 함수는 두 개의 인자를 받아 새로운 한 쌍을 생성하는데, 이 인자는 보통 단일 값과 또 다른 쌍입니다. 이러한 쌍들을 담고 있는 쌍들이 리스트를 형성합니다.

cons 함수 개념은 더 일반적인 함수형 프로그래밍 용어로 나아갑니다: “to cons x onto y”는 약식으로 요소 x를 새로운 컨테이너에 집어넣고, 그다음 컨테이너 y를 넣는 식으로 새로운 컨테이너 인스턴스를 생성하는 것을 의미합니다.

cons list 내의 각 아이템은 두 개의 요소를 담고 있습니다: 현재 아이템의 값과 다음 아이템이지요. 리스트의 마지막 아이템은 다음 아이템 없이 Nil 이라 불리는 값을 담고 있습니다. cons list는 cons 함수를 재귀적으로 호출함으로써 만들어집니다. 재귀의 기본 케이스를 의미하는 표준 이름이 바로 Nil 입니다. 유효하지 않은 값 혹은 값이 없는 것을 말하는 6장의 “null” 혹은 “nil” 개념과 동일하지 않다는 점을 주의하세요.

비록 함수형 프로그래밍 언어들이 cons list를 자주 사용할지라도, 러스트에서는 흔히 사용되는 데이터 구조가 아닙니다. 러스트에서 아이템의 리스트를 갖는 대부분의 경우에는, Vec<T>이 사용하기에 더 나은 선택입니다. 그와는 다른, 더 복잡한 재귀적 데이터 타입들은 다양한 상황들에서 유용하기는 하지만, cons list를 가지고 시작함으로써, 박스가 어떻게 재귀적 데이터 타입을 정의하도록 해주는지 우리의 집중을 방해하는 것들 없이 탐구할 수 있습니다.

Listing 15-2는 cons list를 위한 열거형 정의를 담고 있습니다. 우리가 보여주고자 하는 것인데, List 타입이 알려진 크기를 가지고 있지 않고 있기 때문에 이 코드는 아직 컴파일이 안된다는 점을 유의하세요:

Filename: src/main.rs

enum List {
    Cons(i32, List),
    Nil,
}

Listing 15-2: i32 값의 cons list 데이터 구조를 표현하는 열거형 정의에 대한 첫 번째 시도

노트: 이 예제의 목적을 위해 오직 i32 값만 담는 cons list를 구현하고 있습니다. 우리가 10장에서 논의한 것처럼, 임의의 타입 값을 저장할 수 있는 cons list 타입을 정의하기 위해서는 제네릭을 이용해 이를 구현할 수도 있습니다.

List 타입을 이용하여 리스트 1, 2, 3을 저장하는 것은 Listing 15-3의 코드와 같이 보일 것입니다:

Filename: src/main.rs

use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

Listing 15-3: List 열거형을 이용하여 리스트 1, 2, 3 저장하기

첫 번째 Cons 값은 1List 값을 갖습니다. 이 List 값은 2와 또 다른 List 값을 갖는 Cons 값입니다. 그 안의 List 값은 3List 값을 갖는 추가적인 Cons인데, 여기서 마지막의 ListNil로서, 리스트의 끝을 알리는 비재귀적인 variant입니다.

만일 Listing 15-3의 코드를 컴파일하고자 시도하면, Listing 15-4에 보이는 에러를 얻습니다:

error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^ recursive type has infinite size
2 |     Cons(i32, List),
  |               ----- recursive without indirection
  |
  = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to
  make `List` representable

Listing 15-4: 재귀적 열거형을 정의하고자 시도했을 때 얻게 되는 에러

이 에러는 이 타입이 “무한한 크기를 갖는다”라고 말해줍니다. 그 원인은 우리가 재귀적인 variant를 이용하여 List를 정의했기 때문입니다: 즉 이것은 또 다른 자신을 직접 값으로 갖습니다. 결과적으로, 러스트는 List 값을 저장하는데 필요한 크기가 얼마나 되는지 알아낼 수 없습니다. 왜 우리가 이런 에러를 얻게 되는지 좀 더 쪼개어 봅시다: 먼저, 러스트가 비재귀적인 타입의 값을 저장하는데 필요한 용량이 얼마나 되는지 결정하는 방법을 살펴봅시다.

비재귀적 타입의 크기 계산하기

6장에서 열거형 정의에 대해 논의할 때 우리가 Listing 6-2에서 정의했던 Message 열거형을 상기해봅시다:


# #![allow(unused_variables)]
#fn main() {
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}
#}

Message 값을 할당하기 위해 얼마나 많은 공간이 필요한지를 결정하기 위해서, 러스트는 어떤 variant가 가장 많은 공간을 필요로 하는지를 알기 위해 각각의 variant들 내부를 봅니다. 러스트는 Message::Quit가 어떠한 공간도 필요 없음을 알게 되고, Message::Move는 두 개의 i32 값을 저장하기에 충분한 공간이 필요함을 알게 되고, 그렇게 진행됩니다. 단 하나의 variant만 사용될 것이기 때문에, Message 값이 필요로 하는 가장 큰 공간은 그것의 varient 중 가장 큰 것을 저장하는데 필요한 공간입니다.

러스트가 Listing 15-2의 List 열거형과 같은 재귀적 타입이 필요로 하는 공간을 결정하고자 시도할 때 어떤 일이 일어나는지를 이와 대조해보세요. 컴파일러는 Cons variant를 살펴보는 것을 시작하는데, 이는 i32 타입의 값과 List 타입의 값을 갖습니다. 그러므로, Consi32의 크기에 List 크기를 더한 만큼의 공간을 필요로 합니다. List 타입이 얼마나 많은 메모리를 차지하는지 알아내기 위해서, 컴파일러는 그것의 variants를 살펴보는데, 이는 Cons variant로 시작됩니다. Cons variant는 i32 타입의 값과 List 타입의 값을 갖고, 이 과정은 Figure 15-1에서 보는 바와 같이 무한히 계속됩니다:

An infinite Cons list

Figure 15-1: 무한한 Cons variant를 가지고 있는 무한한 List

Box<T>를 이용하여 알려진 크기를 가진 재귀적 타입 만들기

러스트는 재귀적으로 정의된 타입을 위하여 얼마큼의 공간을 할당하는지 알아낼 수 없으므로, 컴파일러는 Listing 15-4의 에러를 내줍니다. 하지만 이 에러는 아래와 같은 유용한 제안을 포함하고 있습니다:

  = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to
  make `List` representable

이 제안에서, “간접 (indirection)”은 값을 직접 저장하는 대신, 간접적으로 값의 포인터를 저장하기 위하여 데이터 구조를 바꿀 수 있음을 의미합니다.

Box<T>가 포인터이기 때문에, 러스트는 언제나 Box<T>가 필요로 하는 공간이 얼마인지 알고 있습니다: 포인터의 크기는 그것이 가리키고 있는 데이터의 양에 기반하여 변경되지 않습니다. 이는 우리가 Cons variant 내에 또 다른 List 값을 직접 넣는 대신 Box<T>를 넣을 수 있다는 뜻입니다. Box<T>Cons variant 안에 있기보다는 힙에 있을 다음의 List 값을 가리킬 것입니다. 개념적으로, 우리는 다른 리스트들을 “담은” 리스트들로 만들어진 리스트를 여전히 갖게 되지만, 이 구현은 이제 또 다른 것 안의 아이템들이 아니라 또 다른 것 옆에 있는 아이템들에 더 가깝습니다.

우리는 Listing 15-2의 List 열거형의 정의와 Listing 15-3의 List 사용법을 Listing 15-5의 코드로 바꿀 수 있는데, 이는 컴파일될 것입니다:

Filename: src/main.rs

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use 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 variant는 i32와 박스의 포인터 데이터를 저장할 공간을 더한 크기를 요구할 것입니다. Nil variant는 아무런 값도 저장하지 않으므로, Cons variant에 비해 공간을 덜 필요로 합니다. 우리는 이제 어떠한 List 값이 i32의 크기 더하기 박스의 포인터 데이터의 크기만큼을 차지할 것인 점을 알게 되었습니다. 박스를 이용함으로써, 우리는 무한하고, 재귀적인 연결을 부수었고, 따라서 컴파일러는 List 값을 저장하는데 필요한 크기를 알아낼 수 있습니다. Figure 15-2는 Cons variant가 이제 어떻게 생겼는지를 보여주고 있습니다:

A finite Cons list

Figure 15-2: ConsBox를 들고 있기 때문에 무한한 크기가 아니게 된 List

박스는 단지 간접 및 힙 할당만을 제공할 뿐입니다; 이들은 다른 어떤 특별한 능력들, 우리가 다른 스마트 포인터 타입들에서 보게 될 것 같은 능력들이 없습니다. 또한 이들은 이러한 특별한 능력들이 초래하는 성능적인 오버헤드도 가지고 있지 않으므로, 우리가 필요로 하는 기능이 딱 간접 하나인 cons list와 같은 경우에 유용할 수 있습니다. 우리는 또한 17장에서 박스에 대하여 더 많은 사용례를 살펴볼 것입니다.

Box<T> 타입은 스마트 포인터인데 그 이유는 이것이 Deref 트레잇을 구현하고 있기 때문이며, 이는 Box<T> 값이 참조자와 같이 취급되도록 허용해줍니다. Box<T> 값이 스코프 밖으로 벗어날 때, 박스가 가리키고 있는 힙 데이터도 마찬가지로 정리되는데 이는 Drop 트레잇의 구현 때문에 그렇습니다. 이 두 가지 트레잇에 대하여 더 자세히 탐구해 봅시다. 이 두 트레잇이 이 장의 나머지에서 다루게 될 다른 스마트 포인터 타입에 의해 제공되는 기능들보다 심지어 더 중요할 것입니다.