region 안의 컬렉션과 Trivial 계층
늘어나는 Vec을 담을 수 없는 메모리 모델은 쓸 수 없다. Vec·String·Map을 region에 살게 한다는 것은, 성장할 때마다 재할당하는 컨테이너와, 밀어 할당하고 한꺼번에 해제할 뿐인 영역을 화해시키는 일이고, 그리고 region 판과 owned 판을 하나의 교묘한 파라미터화된 타입이 아니라 두 개의 별개 타입으로 유지하기로 일부러 정하는 일이다.
Part II는 원리로부터 메모리 모델을 쌓아 왔다: 다섯 전략을 하나의 region으로 좁히고, region이
담을 수 있는 것을 짚는 Trivial의 선, 담을 수 없는 값을 위한 with, 자기 참조를 위한 뷰
타입. 하지만 그 모두는, 거기에 Vec을 담을 수 없으면 무(無)나 다름없다. 고정된 형태의
데이터만 다루는 메모리 모델은 탁상의 연습이다. 실제 프로그램은 리스트를 늘리고, 문자열을
짜고, 맵을 조회한다. Part II 마지막인 이번 회는, 일상의 컨테이너를 region에 살게 하는
이야기다 —— 그리고 그것은, 또 하나의 진짜 결정을 강요하게 된다.
성장하는 것을, 한 번만 늘어나는 영역에
긴장을 한 장의 그림으로 말하면 이렇다. Vec은 성장한다: 차면, 더 큰 버퍼를 할당하고,
요소를 복사해 옮기고, 옛것을 버린다. region은 그 반대를 한다 —— 포인터를 앞으로 밀 뿐,
개별 할당을 결코 해제하지 않고, 영역째가 블록 종료 시 한 수로 사라진다. 재할당 위에 세워진
컨테이너가, 결코 해제하지 않음 위에 세워진 영역 안에 어떻게 앉는가?
답은, 둘이 언뜻 보이는 것보다 잘 맞물린다는 것이다. region에 사는 Vec은 어떤 Vec이나
하는 대로 재할당한다 —— 새롭고 더 큰 버퍼를 밀고, 요소를 복사하고, 새 쪽을 가리킨다 ——
그리고 그저 옛 버퍼를 해제하지 않는다. 통상의 할당자라면 그것은 누수다. region에서는
아니다: 옛 버퍼는 블록이 끝날 때까지의 죽은 공간이고, 그때 다른 모든 것과 함께, 포인터 한
번의 리셋으로 공짜로 회수된다. region에서의 성장은 그저 “작은 버퍼를 버리고, 계속 민다”일
뿐이다. 개별로 해제하지 않는다는 region의 거부 —— 제한으로 보였던 그것 —— 이, 바로 성장하는
컨테이너를 값싸게 떠받칠 수 있는 이유다.
그리고 성장은 비가시적이지 않다. 재할당할 수 있는 push는, 모델의 나머지가 쓰는 것과 같은
region 할당 이펙트를 띤다 —— 그것을 부르면 region을 더 많이 소비할 수 있다는 사실이, 런타임에
발견되는 것이 아니라 그 타입에 적혀 있다. 리스트를 늘리는 메모리 비용은, 보인다.
Trivial 계층: 무엇이 무엇 안에 중첩되는가
지난 회의 Trivial 제약이, 어느 컨테이너가 region에 놓일 수 있는가 —— 그리고 결정적으로,
어느 것이 어느 것 안에 중첩될 수 있는가 —— 를 정하는 규칙으로 여기서 돌아온다. Trivial[R]은
재귀적으로 정의된다:
- 프리미티브(
int,bool,char, …)는 어느 region에서나Trivial; - region 참조
&R T는,T가Trivial[R]이면Trivial[R]; Vec[R, T]는, 요소 타입T가Trivial[R]이면Trivial[R].
그 연쇄를 따라가면 Vec[R, Vec[R, int]] 자신이 Trivial[R]이 된다 —— 벡터의 벡터가, 통째로
하나의 region 안에서, 여전히 한 번의 일괄 해제로 회수된다. 이 재귀가 바로 핵심이다: “어느
컬렉션이 어느 것 안에 중첩될 수 있는가”는 별도의 규칙집이 아니라, 그저 요소 타입에 적용된
Trivial이다. 컨테이너가 region 안전한 것은, 그것이 추이적으로 담는 모든 것이 region
안전할 때, 꼭 그때이고, 타입 시스템이 그것을 공짜로 검사한다.
그 선은, 무엇이 안에 들어갈 수 없는가도 명확히 말한다. owned String —— heap 뒷받침에
Drop을 가진 —— 은 Trivial이 아니어서, region에 살 수 없다. 그것은 결함이 아니다. 지난
회의 Trivial/Drop 분할이 일을 하고 있는 것이다. 문자열에는 모델이 대신 두 개의 region
네이티브한 형태를 내민다: 불변의 &R str, region에 사는 슬라이스로, 그 부분 문자열도 split도
모두 같은 영역을 가리키므로 자유롭게 공유할 수 있다; 그리고 가변의 StrBuf[R], 텍스트를 짜
올리기 위한 Vec[R, u8]의 얇은 래퍼다. owned로 heap 할당된 문자열도 여전히 존재한다 ——
그저, region 안에서 손을 뻗는 대상이 그것이 아닐 뿐이다.
하나의 결정: 교묘한 하나의 타입이 아니라, 두 개의 타입
이 컨테이너들을 애초에 어떻게 제공하는가에, 진짜 분기가 있었다. 그리고 그것이 이번 회에서 가장 흥미로운 선택이다. 명백한 답이, Mere가 택한 답이 아니기 때문이다.
통합적인 선택지 —— Rust가 손을 뻗는 것 —— 은, 할당자로 파라미터화한 하나의 Vec 타입이다:
Vec[A, T], 어떤 실체화에서는 A가 글로벌 heap 할당자, 다른 실체화에서는 region 할당자.
하나의 구현, 하나의 API, region성은 타입 파라미터로 고른다. 우아하고, 코드 최소화가 목표라면
그렇게 쓸 것이다.
Mere는 다른 길을 택했다: 두 개의 별개 타입. Trivial이고 밀어 할당하는 region
Vec[R, T]와, heap 뒷받침에 Drop을 가진 OwnedVec[T], 그 API의 읽기 부분을 Collection
trait 뒤에서 통일하여, 부르는 쪽은 대개 어느 것을 쥐고 있는지 신경 쓰지 않아도 된다.
이유는, Part II 전체를 빚어 온 것과 같은 본능이다. 할당자 파라미터는, 런타임의 선택 ——
어느 할당자인가 —— 을 타입에 통과시킴으로써, 두 종류의 벡터를 같아 보이게 한다. 하지만
region성은 런타임의 선택이어선 안 된다. region의 가치의 전부는, 그 할당이 디스패치도 값마다의
정리도 없는 그저 포인터 밀기라는 데 있다. owned와 region을 할당자를 거쳐 하나의 타입으로
접으면, Trivial한 타입과 Drop한 타입을 슬며시 같은 이름 뒤에 두고, 앞 두 회가 한 편을
들여 그은 선을 흐린다. 두 타입은 그 선을 날카롭게 유지한다: Vec[R, T]는 늘 Trivial,
OwnedVec[T]는 늘 정리를 가지고, 둘이 호환인 척하는 일이 결코 없다. 둘 사이에서 값을 옮기는
것은 명시적 복사이지, 암묵의 할당자 교체가 아니다 —— 언어가 곳곳에서 하는, 숨은 변환의
거부와 같다.
대가는 있다. 설계 노트는 분명히 말한다: 표준 라이브러리는 이제 push를 두 번 쓴다. 하지만
그것은 한 번, 라이브러리의 작성자가 치르는 비용이고, 공유 trait 뒤에서 다른 모두에게서
숨겨진다 —— 만약 대안이 모든 사용자에게 더 흐린 타입 시스템이라면, 중복을 쓰기에 꼭 옳은
자리다. 이것은 Mere의 선택의 반복되는 형태다: 적은 개념을 슬며시 병합하기보다, 많은 개념을
구별한 채 유지하기를 선호하고, 구별의 대가를 읽는 이의 의심이 아니라 작성자의 수고로 치른다.
Part II, 닫히다
이것으로 메모리 모델이 완성된다. 그것은 다섯 전략의 목록으로 시작해, 더 작고 날카로운
무언가로 끝났다: 밀어 할당하고 한꺼번에 해제하는 하나의 region; region이 담을 수 있는
것과, with가 LIFO 순으로 정리해야 하는 것을 가르는 Trivial의 선; 값이 자신의 region 안으로
안전하게 참조할 수 있게 하는 뷰 타입; 그리고 이제, 해제하지 않고 버림으로써 region 안에서
성장하는 컬렉션과, 무엇이 어디에 중첩될 수 있는지를 정하는 재귀적 Trivial 계층. 어느
부품이나 타입에 명시되고, 탈출구 없이 검사되며, 지면에서 읽어낼 수 있다 —— 그것이 첫 회가
내건 목표의 전부였다.
메모리는 값이 어디에 살고, 언제 죽는가에 답한다. 다음 Part는, 언어가 똑같이 집요하게 명시하게 하려는 다른 물음에 답한다: 어떤 코드가 무엇을 해도 되는가 —— 네트워크에 닿고, 시계를 읽고, 파일을 쓰는가? 그것이 이펙트 시스템이고, 그것은 부작용을 공짜로 이동시키는 것을 거부하는 데서 시작한다. 다음 회: 부작용을 값으로 넘기기.