필드 프로젝션 설계 맥락에서 가상 플레이스를 중심으로 빌림 검사기 동작을 명세하는 방법을 제안하고, 접근 종류와 플레이스 상태라는 두 축으로 참조/스마트 포인터 및 Place* 연산의 상호작용을 설명한다.
URL: https://bennolossin.github.io/blog/field-projections/virtual-places-and-borrowck.html
Title: Virtual Places and Borrow Checker Integration
이 블로그 글에서는 필드 프로젝션 설계 노력의 맥락에서 빌림 검사기 동작을 어떻게 명세할 수 있는지에 대한 아이디어를 제시한다. 이 작업은 Nadrieril의 블로그 글 The Algebra of Loans in Rust에 기반한다. 나는 그 글이 빌림 검사기가 동작하는 일반적인 방식을 다룬 반면, 더 필드 프로젝션에 초점을 맞춘 제안을 제시할 것이다.
필드 프로젝션의 주요 제안은 가상 플레이스(virtual places)에 초점을 맞추며, 이는 Nadrieril이 소개한 바 있다. 비슷한 맥락에서, 나는 이제 플레이스(place)를 중심에 두고 내장 참조 타입뿐 아니라 플레이스를 갖는 사용자 정의 타입들의 빌림 검사기 동작을 명세하는 방법을 제시한다.
또한, 빌림 검사기의 라이프타임을 우리의 제안에 더 잘 포함시키는 아이디어도 제시한다.
현재 설계에는 여러 문제/열린 질문이 있다1.
BORROW_KIND = Untracked인 경우 PlaceBorrow의 라이프타임은 무엇을 의미하는가?BorrowKind의 실제 의미론은 무엇인가?이 글로 이 모든 질문에 답하고자 한다. 그리고 추가로, Rust에서의 빌림을 더 단순하게 설명하는 방법도 제공하고자 한다.
The Algebra of Loans in Rust는 이 글의 전제 조건이며, _플레이스(place)_가 무엇인지, _빌림을 취하는 것(taking a borrow)_과 론(loans) 같은 기본 개념에 대한 좋은 개요를 제공한다. 그 글은 또한 각 참조가 어떤 종류의 연산을 사용할 수 있는지를 명세하는 세 개의 표를 제공한다. 이 글에서는 마지막 두 표를 재현할 것인데2, 표를 직접 명세하는 대신 그 아래에 있는 메커니즘을 명세하는 방식으로 접근한다.
이 _기저 메커니즘_은 두 가지 개념으로 구성된다.
&mut는 배타적(exclusive) 접근이 필요하고, &는 공유(shared) 접근만 필요하며, *const는 어떤 종류의 접근도 필요로 하지 않는다3.&는 플레이스가 초기화되어 있어야 한다. &mut는 플레이스가 _핀(pin)_되어 있지 않아야 한다. *const는 아무 요구도 하지 않는다. 마지막으로 중요한 예로 &own이 있는데, 이는 상태가 초기화되어 있고 핀되어 있지 않아야 하며, 만료(expiry) 시 상태를 미초기화(uninitialized)로 변환한다.이 개념들은 또한 모든 플레이스 연산이 빌림 검사기와 어떻게 상호작용하는지를 설명한다.
PlaceDrop은 배타적 접근을 요구하며, 상태를 초기화됨에서 미초기화로 바꾼다.PlaceRead는 플레이스에 대한 공유 접근을 요구하며, 초기화되어 있음을 기대한다.PlaceMove는 추가로 플레이스가 핀되어 있지 않음을 요구하며, 상태를 미초기화로 바꾼다.PlaceWrite는 배타적 접근을 요구한다. 상태가 초기화되어 있다면 먼저 PlaceDrop을 수행하므로 미초기화 상태를 요구하는 것과 같고, 그렇지 않다면 플레이스가 미초기화 상태이길 기대한다.PlaceDeref는 역참조된 포인터에 대해 이어서 수행될 연산이 필요로 하는 것과 같은 종류의 접근을 요구한다.PlaceBorrow는 자신의 접근과 기대 상태 + 상태 변경을 명세한다.이 부분은 본질적으로 RFC의 “reference-level explanation” 섹션과 비슷하다. 접근 종류와 플레이스 상태를 enum으로 모델링한다.
// 이전 제안들에서는 `BorrowKind`라고도 불림
pub enum PlaceAccess {
Shared,
Exclusive,
Untracked,
}
pub enum PlaceState {
Initialized(PinnedState),
Uninitialized,
}
pub enum PinnedState {
NotPinned,
Pinned,
}
이제 PlaceBorrow 또는 심지어 HasPlace에 두 개의 상수를 추가할 수 있다.
pub trait HasPlace {
const ACCESS: PlaceAccess;
const STATE: PlaceState;
type Target: ?Sized;
}
(여러 상태를 허용하는 플레이스를 제대로 지원하려면 PlaceState 대신 집합(set)이나 다른 enum이 필요할 수도 있음을 유의하라.)
그런 다음 PlaceBorrow에 AFTER: PlaceState 상수도 필요하다. 이는 빌림이 끝난 뒤 플레이스가 어떤 상태여야 하는지를 명세한다.
두 번째 표 “론을 취했고 라이브일 때, 나는 여전히 그 플레이스에 대해 무엇을 할 수 있는가”를 얻기 위해서는, 두 커스텀 포인터(HasPlace를 구현하는 타입들)의 ACCESS 상수만 고려하면 된다. 둘 중 하나가 Untracked이거나 둘 다 Shared라면 공존할 수 있고, 그렇지 않으면 빌림 검사 오류가 발생한다.
예시:
&mut T와 &own T는 둘 다 Exclusive를 원하므로 공존할 수 없다.&T와 ArcMap<T, U>는 둘 다 Shared 접근만 필요하므로 공존할 수 있다.UniqueArcMap<T, U>와 *const T는 원시 포인터가 Untracked 접근이므로 공존할 수 있다.세 번째 표의 경우에는, 빌림이 만료된 뒤 플레이스의 상태만 고려하면 된다. 이 모델에서는 Untracked 빌림을 즉시 만료되는 것으로 만드는 것이 유용하다.
예시:
&own T는 상태를 Uninitialized로 바꾸므로, 뒤이어 &mut T 빌림은 허용되지 않는다(이는 초기화된 상태를 기대한다). &uninit T를 사용하는 빌림은 허용되는데, 이는 미초기화 메모리를 기대하기 때문이다.&mut T는 상태를 바꾸지 않으므로, 뒤이어 &T 빌림이 허용된다.| (스마트) 포인터 또는 연산 | PlaceAccess | 이전 PlaceState4 | 이후 PlaceState |
|---|---|---|---|
&T | Shared | Initialized(_) | 변경 없음 |
&mut T | Exclusive | Initialized(NotPinned) | 변경 없음 |
&own T | Exclusive | Initialized(NotPinned) | Uninitialized |
&uninit T | Exclusive | Uninitialized | ??? |
*const T | Untracked | _ | 변경 없음 |
&pin T | Shared | Initialized(Pinned) | 변경 없음 |
&pin mut T | Exclusive | Initialized(Pinned) | 변경 없음 |
&pin own T | Exclusive | Initialized(Pinned) | Uninitialized |
ArcMap<T, U> | Untracked | Initialized(_) | 변경 없음 |
UniqueArcMap<T, U> | Untracked | Initialized(_) | Uninitialized |
PlaceDrop | Exclusive | Initialized(_) | Uninitialized |
PlaceRead | Shared | Initialized(_)5 | 변경 없음 |
PlaceMove | Exclusive | Initialized(NotPinned) | Uninitialized6 |
PlaceWrite | Exclusive | Uninitialized | Initialized(NotPinned) |
PlaceInit | Exclusive | Uninitialized | Initialized(NotPinned) |
PlacePinInit | Exclusive | Uninitialized | Initialized(Pinned) |
PlaceBorrow | custom | custom | custom |
PlaceDeref | ??? | ??? | ??? |
몇 가지 메모:
&uninit는 제자리 초기화(in-place init)와 크게 겹친다. 빌림 검사기는 여기서 제어 흐름(control flow)을 이해해야 하는데, 에러 경로에서는 메모리가 미초기화로 남지만 성공 경로에서는 초기화되기 때문이다. PlaceState를 사용해 이를 어떻게 추적할지는 확실치 않다. 제자리 초기화는 아직 설계가 확정되지 않았으므로, 당장 지원할 필요는 없다.PlaceWrite는 플레이스가 미초기화이길 기대한다. 이는 Rust의 현재 동작과 일치하는데, write를 수행하기 전에 drop_in_place가 삽입되기 때문이다. 이 동작은 사용자 정의 포인터에서도 물론 유지될 것이다.PlaceDeref는 꽤 특수하다. 어쨌든 그 설계를 다듬는 데 더 시간이 필요하다. 이 글의 관점에서는, 반환된 포인터에 대해 이후 수행되는 연산의 빌림 검사기 동작을 복사(copy)하고 싶을 것이다.PlaceAction enum으로도 인코딩할 수 있다:pub enum PlaceAction {
Nothing,
Initialize(PinnedState),
Uninitialize,
}
이렇게 하면 “변경 없음” 의미론을 더 잘 명세할 수 있다.
이 아이디어는 여러 훌륭한 속성을 동시에 갖추고 있으므로, 올바른 방향으로 잘 가고 있다고 믿는다.
Place* 연산들을 포괄한다.핀을 플레이스 상태로 만들지 않고 Move 트레이트가 있었다면, 그림이 더 단순해졌을 것이다. Move를 현실로 만들어야 한다는 또 하나의 근거다.
PlaceState를 수용하기 위한 최선의 방법은 무엇인가?PlaceState가 달리 변하도록 어떻게 지원할 것인가? 애초에 그런 지원을 원하나?PlaceAction을 명시해야 할까?PlaceDeref는 어떻게 동작하는가?Untracked 접근이 빌림을 “즉시 종료”하는 것이 타당한가?PlaceAccess에서의 라이프타임Nadrieril에게서 영감을 받아7, 접근 종류가 untracked인지 여부에 따라 라이프타임을 사용할 수 있게 만들어보자. 내 아이디어는 enum 대신 타입 시스템을 쓰는 것이다.
#[sealed]
pub trait PlaceAccess {}
pub struct Owned;
impl PlaceAccess for Owned {}
pub struct Shared<'a>(PhantomData<&'a ()>);
impl PlaceAccess for Shared<'_> {}
pub struct Exclusive<'a>(PhantomData<&'a mut ()>);
impl PlaceAccess for Exclusive<'_> {}
이제 HasPlace를 구현할 때 공유/배타 케이스에서는 반드시 라이프타임을 제공해야 한다.
impl<'a, T> HasPlace for &'a mut T {
type Access = Exclusive<'a>;
const STATE: PlaceState = PlaceState::Initialized { pinned: false };
type Target = T;
}
하지만 untracked 케이스에서는 이제 라이프타임이 아예 없다.
impl<T> HasPlace for *const T {
type Access = Untracked;
const STATE: PlaceState = PlaceState::Any;
type Target = T;
}
impl<P: Projection> PlaceBorrow<P, *const P::Target> for *const P::Source {
// ...
}
이렇게 라이프타임을 PlaceAccess에 조건적으로 결합하면, 빌림 검사기가 그 라이프타임을 선택할 수 있게도 된다. 이는 본질적으로 “빌림 검사기가 제어하는 라이프타임”을 표시하는 마커로 동작한다. 이 방식으로 라이프타임 단축을 얻을 수 있는데, 이제 다음과 같은 impl을 쓸 수 있기 때문이다.
impl<'a, 'b: 'a, P: Projection> PlaceBorrow<P, &'a mut P::Target> for &'b mut P::Source {
// ...
}
그리고 &'a mut P::Target는 Access = Exclusive<'a>를 가지므로, 빌림 검사기는 적절한 대로 그 라이프타임을 단축할 수 있다.
_현재 설계_란 Truly First-Class Custom Smart Pointers에서 소개되었고, Zulip에서의 논의를 통해 더 발전된 설계를 의미한다.↩
첫 번째 표는 올바른 타입들에 대해 PlaceBorrow를 구현함으로써 인코딩된다.↩
원시 포인터는 분명 그가 가리키는 대상(pointee)을 읽거나 쓸 수 있지만, 그 연산은 빌림 검사기에 의해 통제되지 않는다. 우리가 여기서 관심 있는 것도 그것이다. 따라서 빌림 검사기의 관점에서 *const는 어떤 종류의 접근도 요구하지 않는다.↩
이전 상태를 지정하기 위해 패턴을 사용하는데, 잠재적으로 여러 상태를 받아들일 수 있기 때문이다.↩
PlaceRead는 Copy인 타입에만 제공된다. 이런 타입들은 애초에 비자명한 핀 불변식을 가질 수 없으므로, 핀 상태는 중요하지 않다.↩
Copy 타입의 경우에는, 물론 상태를 Uninitialized로 바꾸지 않을 것이다. 하지만 내 생각에는 애초에 Copy 타입에 PlaceMove를 쓰지 않으므로, 이는 PlaceRead만으로는 부족한 타입들에만 적용된다.↩
Nadrieril은 새로운 참조 타입의 동작을 명세하기 위해 기존 참조 타입을 사용하자는 아이디어를 제시했다.↩