본문 바로가기

Box<T>와 힙 데이터

Box로 값을 힙에 저장하고 컴파일 시 크기를 알 수 없는 재귀적 타입에 포인터 간접 계층을 도입합니다.

포인터(pointer)는 메모리의 주솟값을 담고 있는 변수에 대한 일반적인 개념입니다.

이 주솟값은 어떤 다른 데이터를 참조합니다.

혹은 바꿔 말하면, ‘가리킵니다.’

러스트에서 가장 흔한 종류의 포인터는 3장에서 배웠던 참조자입니다.

참조자는 & 심볼로 표시하고 이들이 가리키고 있는 값을 빌려옵니다.

보통의 참조자는 소유권을 가져오지 않으며, 이 장에서 다룰 참조 카운트 갱신이나 RefCell의 런타임 대여 검사도 추가하지 않습니다. 이를 참조를 저장하거나 가리킨 메모리에 접근하는 비용까지 없다는 뜻으로 읽지는 마세요.

한편, 스마트 포인터(smart pointer)는 포인터처럼 작동할 뿐만 아니라 추가적인 메타데이터와 능력들도 가지고 있는 데이터 구조입니다.

스마트 포인터의 개념은 러스트 고유의 것이 아닙니다.

스마트 포인터는 C++로부터 유래되었고 다른 언어들에도 존재합니다.

러스트의 표준 라이브러리에는 다양한 종류의 스마트 포인터들이 정의되어 있는데 이를 통해 참조자가 제공하는 것 이상의 기능을 제공합니다.

일반적인 개념을 탐구하기 위하여 몇 가지 스마트 포인터 예제를 살펴보려고 하는데, 그중에는 참조 카운팅(reference counting) 스마트 포인터 타입이 있습니다.

이 포인터는 소유자의 개수를 계속 추적하고, 더 이상 소유자가 없으면 데이터를 정리하는 방식으로, 어떤 데이터에 대한 여러 소유자를 만들 수 있게 해 줍니다.

소유권과 대여의 개념을 가지고 있는 러스트에서, 참조자와 스마트 포인터 사이에는 추가적인 차이점이 있습니다.

참조자가 데이터를 빌리기만 하는 반면, 대부분의 경우 스마트 포인터는 가리킨 데이터를 소유합니다.

우리는 이미 이 책에서 7장의 String과 Vec<T>와 같은 몇 가지 스마트 포인터들을 마주쳤습니다.

비록 그때는 이것들을 스마트 포인터라고 부르지 않았지만요.

이 두 타입 모두 스마트 포인터로 치는데 그 이유는 이들이 어느 정도의 메모리를 소유하고 이를 다룰 수 있게 해 주기 때문입니다.

이들은 또한 메타데이터와 추가 기능 또는 보장성을 갖고 있습니다.

예를 들어 String은 자신의 용량을 메타데이터로 저장하고 자신의 데이터가 언제나 유효한 UTF-8임을 보증하는 추가 능력도 가지고 있습니다.

스마트 포인터는 보통 구조체를 이용하여 구현되어 있습니다.

보통의 구조체와는 달리 스마트 포인터는 Deref와 Drop 트레이트를 구현합니다.

Deref 트레이트는 스마트 포인터 구조체의 인스턴스가 참조자처럼 동작하도록 하여 참조자 혹은 스마트 포인터와 함께 작동하는 코드를 작성할 수 있도록 해줍니다.

Drop 트레이트는 스마트 포인터의 인스턴스가 스코프 밖으로 벗어났을 때 실행되는 코드를 커스터마이징 가능하도록 해 줍니다.

이 절에서는 이 두 개의 트레이트 모두를 다루고 이들이 왜 스마트 포인터에 중요한지 보여줄 것입니다.

스마트 포인터 패턴이 러스트에서 자주 사용되는 일반적인 디자인 패턴임을 생각하면, 존재하는 모든 스마트 포인터를 이번 장에서 다루지는 못할 것입니다.

많은 라이브러리가 자신만의 스마트 포인터를 가지고 있고, 심지어 여러분도 자신만의 것을 작성할 수 있습니다.

여기서는 표준 라이브러리에 있는 가장 일반적인 스마트 포인터들을 다루겠습니다.

  • 값을 힙에 할당하기 위한 Box<T>
  • 복수 소유권을 가능하게 하는 참조 카운팅 타입인 Rc<T>
  • 대여 규칙을 컴파일 타임 대신 런타임에 강제하는 타입인, RefCell<T>를 통해 접근 가능한 Ref<T>와 RefMut<T>

추가로 불변 타입이 내부 값을 변경하기 위하여 API를 노출하는 내부 가변성(interior mutability) 패턴에 대해서 다루겠습니다.

또한 순환 참조(reference cycles)가 어떤 식으로 메모리가 새어나가게 할 수 있으며, 이를 어떻게 방지하는지에 대해서도 논의해보겠습니다.

함께 뛰어들어 볼까요!

이제 가장 단순한 스마트 포인터인 Box<T>부터 살펴보겠습니다.

가장 직관적인 스마트 포인터는 박스(box)인데, Box<T>로 쓰이는 타입입니다.

박스는 스택이 아니라 힙에 데이터를 저장할 수 있도록 해줍니다.

이 절의 지역 변수 예에서는 힙 데이터를 가리키는 Box<T> 핸들을 따로 생각하면 됩니다. 핸들 자체가 반드시 스택에만 있어야 하는 것은 아니며 다른 구조체나 컬렉션 안에도 담을 수 있습니다. 여기의 고정 크기 설명은 크기를 아는 T를 대상으로 합니다.

스택과 힙의 차이에 대해 다시 보고 싶다면 3장을 참조하세요.

박스는 힙 할당과 간접 접근을 제공합니다. 참조 카운팅 같은 추가 기능은 없지만, 할당·해제·간접 접근의 비용까지 없다는 뜻은 아닙니다.

하지만 여러 추가 기능도 없습니다.

박스는 아래와 같은 상황에서 가장 자주 쓰이게 됩니다.

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

첫 번째 상황은 ‘박스로 재귀적 타입 가능하게 하기’ 절에서 보여주겠습니다.

두 번째 경우, 방대한 양의 데이터의 소유권 옮기기는 긴 시간이 소요될 수 있는데 이는 그 데이터가 스택 상에서 복사되기 때문입니다.

이러한 상황에서 성능을 향상시킬 목적으로 박스 안의 힙에 그 방대한 양의 데이터를 저장할 수 있습니다.

그러면 작은 양의 포인터 데이터만 스택 상에서 복사되고, 이 포인터가 참조하는 데이터는 힙의 한 곳에 머물게 됩니다.

세 번째 경우는 트레이트 객체(trait object)라고 알려진 것이고, 16장의 ‘트레이트 객체를 사용하여 다른 타입의 값 허용하기’ 절 전체가 이 주제만으로 채워져 있습니다.

그러니 여기서 배운 것을 16장에서 다시 적용하게 될 것입니다!


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

Box<T>에 대한 사용례를 논의하기 전에, 먼저 문법 및 Box<T> 안에 저장된 값의 사용법을 다루겠습니다.

예제 14-1은 박스를 사용하여 힙에 i32 값을 저장하는 방법을 보여줍니다.

예제 14-1: 박스를 사용하여 i32 값을 힙에 저장하기
src/main.rs
fn main() {
    let b = Box::new(5);
    println!("b = {}", b);
}

변수 b를 정의하여 5라는 값을 가리키는 Box 값을 갖도록 했는데, 여기서 5는 힙에 할당됩니다.

이 프로그램은 b = 5를 출력할 것입니다; 이 경우, 박스 안에 있는 데이터는 마치 이 데이터가 스택에 있는 것처럼 접근 가능합니다.

b가 main의 끝에 도달하는 것처럼 어떤 박스가 스코프를 벗어날 때, 다른 어떤 소유된 값과 마찬가지로 할당은 해제될 것입니다.

이 지역 변수 예에서는 박스 핸들을 정리하고, 박스가 소유한 힙 데이터와 그 할당을 해제합니다.

단일 값을 힙에 집어넣는 것은 그다지 유용하지는 않으므로, 이 같은 방식의 박스 사용은 자주 쓰이지 않을 것입니다.

이처럼 작은 i32 정수의 지역 값은 보통 박스 없이 시작하면 됩니다. 소스의 지역 변수라고 모든 최적화된 실행에서 반드시 스택 슬롯이 생기는 것은 아니며, 이 예제는 할당 위치의 어셈블리를 검증하는 프로그램이 아닙니다.

박스를 쓰지 않으면 허용되지 않을 타입을 박스로 정의하는 경우를 살펴봅시다.

아래 다이어그램은 지역 변수 a에서 b로 박스 핸들의 소유권을 옮겨도 힙 값은 같은 할당에 남는다는 관계를 보여줍니다. 그림 뒤 설명에서는 재귀 타입의 간접화와 정상적인 drop을 구분합니다.

Box 간접화와 소유권 수명

a Box i32=5→b로 핸들 이동/a 사용 불가→b 정상 drop에서 같은 값 정리와 할당 해제가 일어난다. 깊은 복제나 실제 스택 슬롯 배치를 주장하지 않는다.

RUST · UNIQUE OWNERSHIP & INDIRECTION

Box가 이동해도 힙 값은 같은 할당에 남는다

Box는 값을 유일하게 소유합니다. 핸들 이동, 재귀 타입의 간접화, 정상 drop은 서로 다른 역할이며 아래 예는 각각의 경계를 나눠 보여줍니다.

Box 핸들 이동과 같은 힙 값a의 Box를 b로 이동하면 a는 사용 불가, b가 같은 힙 값 5를 소유한다. 그림은 지역 변수와 비영크기 i32의 개념 모델이다.a: Box<i32>이동 뒤 a 사용 불가b: Box<i32>같은 할당을 소유힙의 i32: 5값을 깊게 복제하지 않음let b = a유일 소유권
소유자 이동과 같은 힙 값원 desktop의 세노드두간선이다. a의 Box가 b로 이동하며 이후 a를 사용할수없다. b가 같은힙값5를 소유한다. 첫간선은핸들소유권이동,둘째는소유포인터관계이며 두번할당·힙값복제를뜻하지않는다. stack/heap표현은본문의단순지역변수모델이며Box핸들이항상stack에만있다는주장은아니다.
이동 전 · aBox 핸들이후 a 사용 불가
movemove이동 전 · a · Box 핸들 · 이후 a 사용 불가 → 이동 후 · b · 같은 Box 핸들
이동 후 · b같은 Box 핸들
소유소유이동 후 · b · 같은 Box 핸들 → 같은 힙 할당 · 값: 5 · b가 수명을 소유
같은 힙 할당값: 5b가 수명을 소유

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를 소유한다.

  • a: Box(5) 유효
  • 할당: 하나
  • 포인터 간접 구조와 실제 스택 슬롯 배치를 구분한다.
핸들 이동

let b=a로 같은 할당의 소유자가 바뀐다.

  • a: moved·사용 불가
  • b: 같은 값 5 소유
  • 이동은 깊은 복제나 새 i32 할당이 아니다.
최종 drop

b를 정상 정리해 값과 할당을 해제한다.

  • 소유자: b의 수명 종료
  • 할당: 해제
  • 이전 a가 아니라 최종 소유자가 정리 책임을 갖는다.

Box 간접화와 소유권 수명

a Box i32=5→b로 핸들 이동/a 사용 불가→b 정상 drop에서 같은 값 정리와 할당 해제가 일어난다. 깊은 복제나 실제 스택 슬롯 배치를 주장하지 않는다.

RUST · UNIQUE OWNERSHIP & INDIRECTION

Box가 이동해도 힙 값은 같은 할당에 남는다

Box는 값을 유일하게 소유합니다. 핸들 이동, 재귀 타입의 간접화, 정상 drop은 서로 다른 역할이며 아래 예는 각각의 경계를 나눠 보여줍니다.

Box 핸들 이동과 같은 힙 값a의 Box를 b로 이동하면 a는 사용 불가, b가 같은 힙 값 5를 소유한다. 그림은 지역 변수와 비영크기 i32의 개념 모델이다.a: Box<i32>이동 뒤 a 사용 불가b: Box<i32>같은 할당을 소유힙의 i32: 5값을 깊게 복제하지 않음let b = a유일 소유권
소유자 이동과 같은 힙 값원 desktop의 세노드두간선이다. a의 Box가 b로 이동하며 이후 a를 사용할수없다. b가 같은힙값5를 소유한다. 첫간선은핸들소유권이동,둘째는소유포인터관계이며 두번할당·힙값복제를뜻하지않는다. stack/heap표현은본문의단순지역변수모델이며Box핸들이항상stack에만있다는주장은아니다.
이동 전 · aBox 핸들이후 a 사용 불가
movemove이동 전 · a · Box 핸들 · 이후 a 사용 불가 → 이동 후 · b · 같은 Box 핸들
이동 후 · b같은 Box 핸들
소유소유이동 후 · b · 같은 Box 핸들 → 같은 힙 할당 · 값: 5 · b가 수명을 소유
같은 힙 할당값: 5b가 수명을 소유

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를 소유한다.

  • a: Box(5) 유효
  • 할당: 하나
  • 포인터 간접 구조와 실제 스택 슬롯 배치를 구분한다.
핸들 이동

let b=a로 같은 할당의 소유자가 바뀐다.

  • a: moved·사용 불가
  • b: 같은 값 5 소유
  • 이동은 깊은 복제나 새 i32 할당이 아니다.
최종 drop

b를 정상 정리해 값과 할당을 해제한다.

  • 소유자: b의 수명 종료
  • 할당: 해제
  • 이전 a가 아니라 최종 소유자가 정리 책임을 갖는다.

박스로 재귀적 타입 가능하게 하기

재귀적 타입(recursive type)의 값은 자신 안에 동일한 타입의 또 다른 값을 담을 수 있습니다.

러스트는 컴파일 타임에 어떤 타입이 얼마만큼의 공간을 차지하는지 알아야 하기 때문에 재귀적 타입은 문제를 일으킵니다.

재귀적 타입의 값 중첩은 이론적으로 무한히 계속될 수 있으므로, 러스트는 이 값에 얼마만큼의 공간이 필요한지 알 수 없습니다.

박스는 알려진 크기를 갖고 있으므로, 재귀적 타입의 정의에 박스를 집어넣어서 재귀적 타입을 가능하게 할 수 있습니다.

재귀적 타입의 예제로, 콘스 리스트(cons list)를 탐구해봅시다.

이것은 함수형 프로그래밍 언어에서 흔히 발견되는 데이터 타입입니다.

여기서 정의할 콘스 리스트 타입은 재귀를 제외하면 직관적입니다; 따라서 여기서 작업할 예제의 개념은 재귀적 타입을 포함하는 더 복잡한 경우에 직면하더라도 유용할 것입니다.

콘스 리스트에 대한 더 많은 정보

콘스 리스트는 Lisp 프로그래밍 언어 및 그의 파생 언어들로부터 유래된 데이터 구조로서 중첩된 쌍으로 구성되며, 연결 리스트(linked list)의 Lisp 버전입니다.

이 이름은 Lisp의 (‘생성 함수(construct function)’의 줄임말인) cons 함수에서 유래되었는데, 이 함수는 두 개의 인수로부터 새로운 쌍을 생성합니다.

cons에 어떤 값과 다른 쌍으로 구성된 쌍을 넣어 호출함으로써 재귀적인 쌍으로 이루어진 콘스 리스트를 구성할 수 있습니다.

예를 들어, 다음은 1, 2, 3 리스트를 담고 있는 콘스 리스트를 각각의 쌍을 괄호로 묶어서 표현한 의사 코드입니다.

(1, (2, (3, Nil)))

콘스 리스트의 각 아이템은 두 개의 요소를 담고 있습니다.

현재 아이템의 값과 다음 아이템이지요.

리스트의 마지막 아이템은 다음 아이템 없이 Nil이라 불리는 값을 담고 있습니다.

콘스 리스트는 cons 함수를 재귀적으로 호출함으로써 만들어집니다.

재귀의 기본 케이스를 의미하는 표준 이름이 바로 Nil입니다.

5장의 ‘널(null)’ 혹은 ‘닐(nil)’ 개념과 동일하지 않다는 점을 주의하세요.

이들은 값이 유효하지 않거나 없음을 말합니다.

콘스 리스트는 러스트에서 흔히 사용되는 데이터 구조는 아닙니다.

러스트에서 아이템 리스트를 쓰는 대부분의 경우에는 Vec<T>가 더 나은 선택입니다.

그와는 다른, 더 복잡한 재귀적 데이터 타입들은 다양한 상황에서 유용하기는 하지만, 이 장에서는 콘스 리스트로 시작하여 박스가 어떤 식으로 별로 주의를 기울이지 않고도 재귀적 데이터 타입을 정의하도록 하는지 탐구하겠습니다.

예제 14-2는 콘스 리스트를 위한 열거형 정의를 담고 있습니다.

List 타입의 크기를 알 수 없기 때문에 이 코드는 아직 컴파일이 안 된다는 점을 유의하세요.

여기서 보여주려는 것이 바로 그 점입니다.

예제 14-2: i32 값의 콘스 리스트 데이터 구조를 표현하는 열거형 정의에 대한 첫 번째 시도
src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

Note: 이 예제의 목적을 위해 오직 i32 값만 담는 콘스 리스트를 구현하는 중입니다. 9장에서 논의했던 것처럼, 제네릭을 이용하면 임의의 타입 값을 저장할 수 있는 콘스 리스트 타입을 정의할 수도 있습니다.

List 타입을 이용하여 리스트 1, 2, 3을 저장하면 예제 14-3의 코드처럼 보일 것입니다.

예제 14-3: List 열거형을 이용하여 리스트 1, 2, 3 저장하기
src/main.rs
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 값을 갖는 Cons가 하나 더 있는데, 여기서 마지막의 List는 Nil로서, 리스트의 끝을 알리는 비재귀적인 배리언트입니다.

예제 14-3 코드의 컴파일을 시도하면, 예제 14-4와 같은 에러를 얻습니다.

예제 14-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>),
  |               ++++    +

For more information about this error, try `rustc --explain E0072`.
error: could not compile `cons-list` due to previous error

이 에러는 이 타입이 ‘무한한 크기다(infinite size)’라고 말해줍니다.

그 원인은 재귀적인 배리언트를 이용하여 List를 정의했기 때문입니다.

즉 이것은 자신의 또 다른 값을 직접 갖습니다.

결과적으로, 러스트는 List 값을 저장하는 데 필요한 크기가 얼마나 되는지 알아낼 수 없습니다.

왜 이런 에러가 생기는지 쪼개서 봅시다.

먼저, 러스트가 비재귀적인 타입의 값을 저장하는 데 필요한 용량이 얼마나 되는지 결정하는 방법을 살펴봅시다.

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

5장에서 열거형 정의에 대해 논의할 때 예제 5-2에서 정의했던 Message 열거형을 상기해봅시다.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

Message 값을 할당하기 위해 필요한 공간의 양을 결정하기 위해서, 러스트는 각 배리언트들의 내부를 보면서 어떤 배리언트가 가장 많은 공간을 필요로 하는지를 알아봅니다.

러스트는 Message::Quit가 어떠한 공간도 필요 없음을, Message::Move는 두 개의 i32 값을 저장하기에 충분한 공간이 필요함을 알게 되고, 그런 식으로 진행됩니다.

한 값에서는 하나의 배리언트만 사용하므로 모든 배리언트의 필드를 합해서 저장하지는 않습니다. 어느 배리언트든 담을 수 있는 공간을 마련하되, 실제 Message 크기는 판별 정보와 정렬·패딩도 함께 반영합니다.

러스트가 예제 14-2의 List 열거형과 같은 재귀적 타입이 필요로 하는 공간을 결정하는 시도를 할 때는 어떤 일이 일어날지 위의 경우와 대조해보세요.

컴파일러는 Cons 배리언트를 살펴보기 시작하는데, 이는 i32 타입의 값과 List 타입의 값을 갖습니다.

그러므로 Cons는 i32와 또 다른 List 값을 직접 저장할 공간이 모두 필요합니다. 정렬·패딩을 고려하기 전부터 List 자신의 크기를 다시 알아야 한다는 문제가 생깁니다.

List 타입이 얼마나 많은 메모리를 차지하는지 알아내기 위해서, 컴파일러는 그것의 배리언트들을 살펴보는데, 이는 Cons 배리언트로 시작됩니다.

Cons 배리언트는 i32 타입의 값과 List 타입의 값을 갖고, 이 과정은 그림 14-1에서 보는 바와 같이 무한히 계속됩니다.

Cons 안에 List가 끝없이 중첩되어 크기를 계산할 수 없는 재귀 타입 그림 14-1: 무한한 Cons 배리언트를 가지고 있는 무한한 List

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

러스트는 재귀적으로 정의된 타입을 위하여 얼마만큼의 공간을 할당하는지 알아낼 수 없으므로, 컴파일러는 에러와 함께 아래와 같은 유용한 제안을 제공합니다.

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

이 제안에서 ‘간접(indirection)’이란, 값을 직접 저장하는 대신 데이터 구조를 바꿔 값을 가리키는 포인터를 저장하는 식으로 값을 간접적으로 저장해야 함을 의미합니다.

이 예제의 Box<List>는 크기를 아는 List를 가리키는 고정 크기 핸들로 표현됩니다.

포인터의 크기는 그것이 가리키고 있는 데이터의 양에 따라 변경되지 않습니다.

이는 Cons 배리언트 내에 또 다른 List 값을 직접 넣는 대신 Box<T>를 넣을 수 있음을 의미합니다.

Box<List> 핸들은 Cons 배리언트의 필드 안에 저장되고, 그 핸들이 가리키는 다음 List 값은 힙에 놓입니다.

개념적으로는 여전히 다른 리스트들을 담은 리스트로 만들어진 리스트지만, 이 구현은 이제 아이템을 다른 것 안쪽에 넣는 것이 아니라 그다음 위치에 놓는 형태에 더 가깝습니다.

예제 14-2의 List 열거형의 정의와 예제 14-3의 List 사용법을 예제 14-5의 코드로 바꿀 수 있고, 이것은 컴파일될 것입니다.

예제 14-5: 알려진 크기를 갖도록 하기 위해 Box<T>를 이용한 List 정의
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))))));
}

Cons 배리언트에는 i32와 박스의 포인터 데이터를 모두 저장할 공간이 필요합니다.

Nil 배리언트에는 저장할 필드가 없지만, Nil로 생성한 값도 같은 List 타입의 크기를 갖습니다. 배리언트의 필드 크기와 전체 열거형 값의 크기는 구분해야 합니다.

이제 Cons의 재귀 필드는 고정 크기 포인터로 표현되므로 모든 List 값의 크기를 유한하게 계산할 수 있습니다. 실제 enum 크기는 필드 크기뿐 아니라 판별 정보와 정렬·패딩도 반영하므로 단순 합과 같다고 단정할 수는 없습니다.

박스를 이용하는 것으로 무한한 재귀적 연결을 깨뜨렸고, 따라서 컴파일러는 List 값을 저장하는 데 필요한 크기를 알아낼 수 있습니다.

그림 14-2는 Cons 배리언트가 이제 어떻게 생겼는지를 보여주고 있습니다.

Box 포인터로 재귀 연결을 끊어 크기가 유한해진 Cons 리스트 그림 14-2: Cons가 Box를 들고 있기 때문에 무한한 크기가 아니게 된 List

박스는 그저 간접 및 힙 할당만을 제공할 뿐입니다; 박스에는 다른 스마트 포인터 타입들에서 보게 될 특별한 기능은 없습니다.

또한 이들은 이러한 특별한 능력들이 초래하는 성능적인 오버헤드도 가지고 있지 않으므로, 필요한 기능이 간접 하나인 콘스 리스트와 같은 경우에는 유용할 수 있습니다.

16장에서 박스에 대한 더 많은 사용례도 살펴볼 예정입니다.

Box<T> 타입은 Deref 트레이트를 구현하고 있기 때문에 스마트 포인터이며, 이는 Box<T> 값이 참조자와 같이 취급되도록 허용해 줍니다.

Box<T> 값이 스코프 밖으로 벗어날 때, 박스가 가리키고 있는 힙 데이터도 마찬가지로 정리되는데 이는 Drop 트레이트의 구현 때문에 그렇습니다.

이 두 트레이트가 이 장의 나머지에서 다루고자 하는 다른 스마트 포인터 타입에 의해 제공되는 기능들보다도 심지어 더 중요할 것입니다.

예시

아래 전체 프로그램은 박스의 이동과 내부 배열 변경을 나누어 보여 줍니다. 배열 자체의 값을 복제하는 대신 박스의 유일 소유자를 바꿉니다.

src/main.rs
fn main() {
    let a = Box::new([2, 4, 6]);
    let mut b = a;
    println!("before={}", b.iter().sum::<i32>());
    b[1] = 9;
    println!("after={}", b.iter().sum::<i32>());
}

예상 출력은 before=12, after=17입니다. a는 이동되어 다시 사용할 수 없고, b를 가변 바인딩으로 선언했으므로 소유한 배열을 변경합니다. 숫자 배열을 힙에 두는 것이 더 빠르다는 측정은 포함하지 않습니다.

스스로 간접화를 확인하기

첫째, 위 코드에서 let mut b = a를 let mut b = a.clone()으로 바꾸었다고 합시다. 이 조건의 Box<[i32; 3]>는 내부 배열도 복제할 수 있습니다. b[1] = 9 뒤 a의 합도 17이 되나요? 둘째, 원 예제 14-5에는 Box::new가 몇 번 적혀 있고, 마지막 박스도 Cons를 담나요? Nil이 리스트의 끝이라는 사실과 List 타입의 크기를 구별하세요.

소유 값과 타입 크기 확인

명시적인 clone은 이 배열을 담은 별도 박스를 만들므로 a의 배열은 [2, 4, 6], 합은 12입니다. 소유권 이동과 명시적인 값 복제는 같은 동작이 아닙니다. 원 리스트에는 Box::new가 세 번 적혀 있고 마지막 하나는 Nil을 담습니다. 종결 배리언트도 List 타입의 값이며, Nil이라고 자동으로 포인터가 없거나 타입 크기가 0인 것은 아닙니다. 이 답은 소스에 있는 생성 호출을 센 것이며 최적화된 실행에서 실제 allocator 호출 횟수를 측정한 결과는 아닙니다.

박스의 소유권과 크기를 아는 타입의 포인터 표현은 표준 Box 문서를 참고하세요.

이 두 트레이트에 대하여 더 자세히 탐구해봅시다.