Rust의 Pin이 만들어낸 사용성의 복잡도를 “장소(place)” 개념으로 풀어내고, 언어 차원에서 고정(pinned) 장소와 고정 참조 문법을 도입해 기존 Pin 기반 생태계와 완전히 호환되면서도 더 직관적으로 사용하는 방안을 제안합니다.
이전 글에서 저는 Rust의 Pin 타입의 목표와 그것이 어떻게 탄생했는지의 역사를 설명했습니다. 2018년에 이 API를 초기 설계할 때, 우리의 명시적 목표 중 하나는 Rust에 가하는 변경의 수를 최소화하는 것이었습니다. 가능한 한 빨리 async/await 문법의 “최소 기능 제품”을 출시하고 싶었기 때문이죠. 이는 Pin이 표준 라이브러리 안에 정의된 타입이며, 메서드 수신자(receiver)로 사용할 수 있다는 점을 제외하면 별도의 문법적·언어적 지원이 없다는 뜻이었습니다. 이전 글에서 썼듯이, 제 의견으로는 이것이 사용자가 Pin과 상호작용해야 할 때 “복잡성 절벽”이 생기는 근본 원인입니다.
우리는 이 선택을 했을 때, 고정된 참조(pinned reference)가 일반 참조보다 더 쓰기 어렵고 혼란스러울 것이라는 점은 알고 있었습니다. 다만 그 어려움의 크기를 다소 과소평가했다고 생각합니다. 우리의 초기 기대는 async/await와 함께라면 핀이 배경으로 사라질 것이라는 것이었습니다. 즉, await 연산자와 런타임의 spawn 함수가 여러분의 future를 대신 핀 처리(pinning)해 주고, 사용자는 이를 직접 마주하지 않아도 되게 만드는 것이었죠. 실제로는 async/await를 쓰더라도 사용자가 고정된 참조와 상호작용해야 하는 경우가 여전히 남아 있습니다. 그리고 때로는 사용자들이 더 낮은 수준의 레지스터로 “내려가서” 스스로 Future를 구현해야 합니다. 이때 정말 거대한 복잡성 절벽을 마주하게 됩니다. 상태 기계를 “수작업”으로 구현하는 본질적 복잡성에 더해, Pin 관련 API를 이해하는 추가 복잡성이 겹치기 때문입니다.
제가 이전 글에서 주장한 바는, 이런 어려움의 상당 부분이 고정된 타입 상태(typestate)라는 개념의 본질적 복잡성이나 이를 표현하는 방식으로서의 고정된 참조의 복잡성 때문이 아니라, Pin이 언어의 지원 없이 순수한 라이브러리 타입이라는 사실에서 기인한다는 점입니다. Pin을 다루는 사용자는 거의 항상 메모리 안전한 일을 하고 있습니다. 문제는 단지, Pin을 쓸 때의 관용구가 일반 참조의 관용구와 다르고 덜 명료하다는 데 있습니다.
이 글에서는, 현존하는 언어와 Pin 위에 구축된 async 생태계와 완전히 하위 호환되면서도 고정된 참조와의 상호작용을 일반 참조와 훨씬 더 유사하게 만드는 일련의 언어 변경을 제안하려 합니다.
우리는 흔히 식(expression)이 값(value)으로 평가된다고 배웁니다. 예를 들어, 식 2 + 2는 식 4와 같은 값으로 평가됩니다. 하지만 변경 가능한 상태를 가진 명령형 언어에서는 장소(place) 로 평가되는 식의 범주가 있습니다. 이러한 장소는 값을 저장하고 나중에 불러올 수 있는 메모리의 위치를 뜻합니다. 예컨대, 변수 이름은 장소입니다. 역참조 연산이나 필드 접근도 마찬가지죠. 장소 표현식을 값 문맥에서 사용하면, 그 장소에 현재 있는 값으로 평가됩니다. 장소이기 때문에, 이런 종류의 식은 값으로는 할 수 없는 방식으로도 사용될 수 있습니다. 예를 들어 대입이 그렇습니다. 4 = x는 쓸 수 없지만 x = 4는 가능합니다.
이 장소와 값의 구분은 C++ 같은 다른 언어에서 “lvalue(장소)”와 “rvalue(값)”로 불리는 구분과 동일합니다. rvalue/lvalue라는 용어는 Christopher Strachey의 훌륭한 1967년 저작 Fundamental Concepts in Programming Languages에서 유래했으며, 두 종류의 식의 개념적 차이를 매우 명확하고 철저하게 제시합니다. Rust 프로젝트는 Strachey와는 달리 “lvalue”와 “rvalue” 대신 “place(장소)”와 “value(값)”라는 용어를 택했습니다. 그게 더 명확하고 덜 헷갈린다고 믿었기 때문이죠(저도 동의합니다). Rust 레퍼런스에는 Rust의 다양한 장소 표현식과 값 표현식이 완전히 열거되어 있습니다.
제대로 이해하자면, 명령형 프로그래밍은 객체 의 세계 위에서 동작하는 코드이며, 이 객체들은 타입 을 가지고, 해당 객체가 가질 수 있는 값 의 집합을 열거합니다. 그리고 이 객체들은 장소 에 존재합니다. 그 결과 객체는 정체성(identity) 도 갖습니다. 서로 다른 장소에 있는 두 객체가 같은 값을 가질 수는 있지만, 동일한 정체성을 갖지는 않습니다. 어떤 프로그래머든 이런 사실을 직관적으로 알고 있지만, 우리는 프로그래밍에 대해 말할 때 이를 종종 생략하고, 심지어 자신이 사용하는 자연어의 문법을 설명하지 못하면서도 잘 사용하는 것처럼 이를 명확히 설명하지 못하곤 합니다.
제가 이런 이야기를 꺼내는 이유는, Rust에서는 예를 들어 가변성 이 바로 장소 의 속성이라는 점을 환기하기 위해서입니다. 우리는 흔히 가변성과 불변성에 대해 이야기할 때 “가변 값”이라는 표현을 쓰지만, 사실 값 그 자체는 전혀 가변적이지 않습니다. 4라는 값은 변하지 않죠! 가변성은 특정 시점의 연산에서 그 객체의 값을 바꿀 수 있는 연산을 통제하는 장소의 속성입니다. 본질적으로, 불변 장소는 대입될 수 없습니다. 장소는 변수만이 아니라, (예를 들어) 참조의 대상이 될 수도 있다는 점을 기억하세요.
Rust에서 장소의 속성은 가변성뿐만이 아닙니다. 예를 들면, 장소는 빌려질 수도 있으며, 그렇게 빌려지면 빌려진 동안 해당 장소에서 수행할 수 있는 다른 연산이 제한됩니다. 또한 장소는 이동(move)될 수 있으며, 그러면 그 장소는 어떠한 연산에도 더 이상 유효하지 않게 됩니다. 그리고 장소가 어떤 상태에 있는지는 그 장소가 존재하는 생애(lifetime) 동안 변할 수 있습니다.
많은 언어는 장소 사용에 대한 이런 수준의 제어를 허용하지 않습니다. 점점 더 많은 언어가 가변성 제한 옵션을 제공하고는 있지만, 객체가 사용되는 횟수를 제한할 수 있는 권한을 주는 경우는 드뭅니다. 사실, 장소에 대한 이러한 제약은 Rust의 핵심 가치 제안이라고 할 수 있습니다. Peter Landin의 1965년 저작 “The next 700 programming languages” 논의에서, 우리가 오늘날 “함수형 언어”라고 부를 DLs에 대해 Christopher Strachey가 남긴 흥미로운 논평이 있습니다. 여기서 “DLs”는 오늘날의 “함수형 프로그래밍 언어”를 의미합니다:
DL의 중요한 특성은 동치 관계를 만들어낼 수 있다는 점, 특히 Peter Landin이 그의 논문에서 (β)라고 부른 치환 규칙입니다. 대입문을 허용하면 그 동치 관계는 성립하지 않습니다. DL의 가장 큰 장점은 그러한 동치 관계를 제공함으로써 프로그램 변환의 동등성을 증명할 희망을 주고, 그것들을 결합하고 조작하기 위한 계산법을 갖기 시작한다는 점입니다. 이는 현재로서는 우리가 갖지 못한 것입니다.
…DL은 모든 언어의 부분집합을 이룹니다. 흥미로운 부분집합이지만, 익숙하지 않다면 사용하기 불편한 부분집합입니다. 우리는 DL이 필요합니다. 왜냐하면 현재로서는 명령문과 점프를 포함하는 언어로 증명을 구성하는 방법을 모르기 때문입니다. [강조 추가]
(이 인용문은 Stack Overflow의 “What is referential transparency?”라는 질문에 대한 Uday Reddy의 훌륭한 답변들(1, 2)을 통해 알게 되었습니다.)
간단히 말해, Rust는 대입을 가진 프로그램의 동작을 분석하는 능력을 개선하기 위해, 대입될 수 있는 장소의 동작에 제어를 도입하려는 시도입니다. 다소 곁다리 이야기이기는 하지만, 이 글의 나머지 부분에서 중요한 것은 Rust에는 장소라는 개념이 있고, 가변성과 이동에 대한 제어는 사실 값이 아니라 장소에 적용된다는 점입니다.
핵심 아이디어는, 가변/불변 장소를 구분하듯이 고정된(pinned) 장소와 고정되지 않은(unpinned) 장소를 언어 차원에서 구분하는 것입니다. (이 개념적 틀은 Jules Bertholet에게서 빚졌지만, 제 제시는 몇 가지 점에서 다를 수 있습니다.) 고정된 장소에 있는 객체는 고정된 타입 상태(typestate)에 있습니다. 이것이 표면 언어에서 고정된 타입 상태를 표현하는 방식입니다.
오늘날 우리는 고정된 포인터(pinned pointer)에 기반한 라이브러리 API를 사용해서 고정된 장소라는 개념을 에뮬레이션 합니다. 즉, 고정된 포인터의 대상(target)은 고정된 장소가 가질 의미론과 동일한 의미론을 갖습니다. 장소의 이러한 성질이 간접적인 에뮬레이션을 통해서만 달성된다는 사실이 Pin을 둘러싼 혼란의 큰 원인입니다. 대신 Rust는 가변성을 강제하는 방식과 유사한 방식으로, 고정된 장소의 규칙을 언어 차원에서 직접 강제할 수 있습니다. 이는 완전히 하위 호환될 것이며, 사용 경험을 일반 참조를 사용할 때와 유사하게 만들 것입니다.
고정된 장소의 객체는 그 자리에서 이동될 수 없으며 &mut 연산자로 빌릴 수 없습니다. 하지만 (가변이라면) 대입할 수 있고 & 연산자로 빌릴 수는 있습니다. 이것이 현재 Pin이 Deref 구현과 Pin::set 메서드로 에뮬레이션하는 동작입니다. 또한 고정된 참조 연산자(다음 절에서 소개)가 있어야 하며, 이 연산자는 오직 고정된 장소에만 적용할 수 있습니다.
그렇다면 어떻게 고정된 장소를 만들까요? 첫째, 고정된 포인터가 가리키는 모든 장소는 고정된 장소여야 합니다. 예를 들어 Pin<&mut T>가 있다면, 그 역참조 대상은 고정된 장소입니다(가변 장소이자 빌린 장소이기도 하지만, 이는 논외로 하죠). 그러나 새로운 언어 기능은 스택 위에 고정된 바인딩을 만들어, 가변 바인딩을 하듯이 고정된 장소를 만들 수 있다는 것입니다:
// `stream`은 고정된(pinned), 가변 장소입니다:
let pinned mut stream = make_stream();
만약 고정된 장소의 타입이 Unpin을 구현한다면, 그 장소에는 위 제약이 적용되지 않습니다. 즉, 그 자리에서 이동하거나 가변 참조를 빌릴 수 있습니다. 이는 Unpin 타입의 장소는 고정되지 않은 것이라 말할 수도 있고, 혹은 고정된 장소의 제약이 Unpin 타입의 장소에는 적용되지 않는다고도 말할 수 있습니다. 사용자 관점에서는 같은 뜻입니다.
Rust가 고정된 장소를 갖게 되면, 일반 참조와 유사한 고정된 참조에 대한 네이티브 문법도 필요합니다. 예를 들어:
// `stream`은 고정된(pinned), 가변 장소입니다:
let pinned mut stream: Stream = make_stream();
// `stream_ref`는 stream에 대한 고정된, 가변 참조입니다:
let stream_ref: &pinned mut Stream = &pinned mut stream;
&pinned T와 &pinned mut T 타입은 기존의 Pin<&T>와 Pin<&mut T>에 대한 문법적 설탕(sugar)에 불과합니다. 이렇게 하면 새 문법을 사용하는 모든 코드는 현재 라이브러리 타입을 사용하는 코드와 완전히 상호 운용됩니다. 다만 고정된 참조 연산자 는 기존 방법보다 더 관용적이고 강력하게 고정된 참조를 구성하는 새로운 방식이 됩니다.
고정된 참조 연산자는 오직 고정된 장소에만 적용할 수 있습니다. 값을 고정된 장소로 이동시키고 고정된 참조를 산출하는 pin! 매크로와의 차이는, 고정된 참조 연산자는 반복해서 적용할 수 있다는 점입니다. 이는 고정된 참조가 가변 참조와 훨씬 더 유사하게 동작하도록 만듭니다. 가변 장소에 가변 참조를 만들 수 있듯, 어떤 고정된 장소에도 고정된 참조를 만들 수 있고, 이후에도 다시 만들 수 있습니다.
컴파일러는 고정된 가변 참조에 대해서도 일반 가변 참조와 동일한 재대여(re-borrow) 동작을 구현할 것입니다. 따라서 사용자는 더 이상 재대여를 위해 Pin::as_mut을 사용할 필요가 없습니다. 이렇게 하면 고정된 참조의 의미론은 일반 참조와 동일해집니다.
잘 작동하게 하려면 마지막으로 한 가지가 더 필요합니다. 참조를 받는 메서드를 호출할 때, 여러분은 참조를 명시적으로 넣을 필요가 없습니다. 예를 들어, 매번 빌린 메서드를 호출하려고 이렇게 쓸 필요는 없습니다:
let capacity = (&vec).capacity();
고정된 메서드도 마찬가지여야 합니다. 메서드 해석(method resolution)을 수행할 때, 컴파일러는 메서드 후보를 평가하기 위해 기존 참조 연산자를 삽입하듯이 고정된 참조 연산자도 삽입해야 합니다.
예를 들어, Stream 트레이트에는 다음 요소로 평가되는 future를 돌려주는 next 어댑터가 있습니다. 오늘날에는 pin! 매크로 같은 것을 사용해 스트림을 제자리에 핀 처리해야 합니다:
// 현재: 매크로로 스트림을 핀 처리하고 as_mut으로 명시적으로 재대여해야 함:
let mut stream = pin!(make_stream());
stream.as_mut().next().await;
stream.as_mut().next().await;
앞 절의 제안과 함께라면, 대신 이렇게 쓸 수 있습니다:
let pinned mut stream = make_stream();
(&pinned mut stream).next().await;
(&pinned mut stream).next().await;
하지만 컴파일러가 메서드 해석에 고정된 참조를 포함하고, 자동으로 이를 삽입한다면, 바인딩에 표시된 표식만 남고 핀 처리는 사라집니다:
let pinned mut stream = make_stream();
// 각 next 호출에 대해 `&pinned mut` 연산자를 삽입:
stream.next().await;
stream.next().await;
이는 정확히 일반 가변 메서드가 동작하는 방식과 동일하지만, 핀에 적용한 것입니다. 그리고 스트림이 Unpin을 구현한다면, 이러한 메서드를 호출한 뒤에도 자유롭게 그것을 다시 이동할 수 있습니다.
Pin methods easier to implement언어에 고정된 장소에 대한 네이티브 강제와 문법을 추가하면, async/await 등을 사용하는 Rust의 “고수준” 레지스터에서 고정된 객체를 다루는 일이 훨씬 쉬워집니다. 하지만 때로는 “저수준” 레지스터로 내려가 async/await 대신 Future나 Stream 같은 고정된 트레이트를 직접 구현해야 할 때도 있습니다. 이 기능들은 그 경우에도 일을 더 쉽게 만들어 줍니다.
첫 번째는 고정된 참조를 일반 참조와 동등한 수준으로 끌어올리는 약간의 문법적 설탕입니다. self: Pin<&mut Self>라고 쓰는 대신, 다른 참조 수신자와 마찬가지로 고정된 메서드 수신자를 위한 특별 문법이 있어야 합니다:
trait Future {
type Output;
fn poll(&pinned mut self, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
trait Stream {
type Item;
fn poll_next(&pinned mut self, cx: &mut Context<'_>) -> Poll<Option<Self::Item>>;
}
(이 기능의 더 나은 버전을 이후 글 UnpinCell에서 발전시켰습니다.)
고정된 메서드에 가장 큰 개선은, pin-project-lite 같은 크레이트에 의존하지 않고도 필드에 대한 안전한 고정 프로젝션(projection, 투영)을 지원하는 것입니다. 불행히도, 이전 글에서 논의한 Drop 문제 때문에, 고정된 프로젝션은 일반 참조 프로젝션처럼 기본적으로 안전할 수 없습니다. 따라서 타입에 “고정 프로젝션에 동의한다(opt-in)”는 것을 나타내는 어노테이션이 필요합니다.
고정 프로젝션에 특별한 어노테이션이 필요한 두 번째 이유도 있습니다. 어떤 타입에서는 그 타입에 대한 고정된 참조가 필드에 대한 고정된 참조를 암시하지 않게 하길 원할 수 있습니다. 예컨대 그 필드는 이 타입의 생애 동안 고정되지 않은 타입 상태로 남아 있어야 하고, 나중에 다른 곳에서 고정될 수 있기 때문입니다.
이런 이유로, 타입의 필드는 이제 자신이 속한 객체가 고정되면 자신도 고정된다고 말하는 어노테이션을 가질 수 있습니다:
struct Foo {
pinned bar: Bar,
baz: Baz,
}
이는 Foo가 고정되면, 그 bar 필드도 고정된다는 뜻입니다. 그 필드에 대한 프로젝션은 Foo가 들어 있는 장소와 마찬가지로 고정된 장소로 간주되며, 동일한 허용과 제약을 갖습니다. 즉, 그 위에서 고정된 메서드와 공유(불변) 메서드를 호출하고 대입할 수 있지만, 가변 메서드를 호출하거나 그 자리에서 이동할 수는 없습니다.
하지만 타입에 pinned 필드가 하나라도 있으면, 다른 필드들은 “고정되지 않음(unpinned)”으로 간주됩니다. 이는 해당 필드에 대한 프로젝션이 고정 상태를 유지하지 않으며, 그 장소는 고정되지 않은 장소라는 뜻입니다. 결과적으로 그 장소에 대해 고정된 참조는 얻을 수 없지만, 일반 가변 참조는 얻을 수 있고 그 자리에서 이동할 수 있습니다.
이것이 사운드하려면, pinned로 표시된 필드를 가진 타입에는 두 가지 검사가 필요합니다. 첫째, 어노테이션을 담은 타입은 Unpin을 명시적으로 구현할 수 없습니다. 오로지 자동 트레이트(auto trait) 메커니즘을 통해서만 Unpin을 구현할 수 있습니다(즉, 모든 필드가 Unpin일 때만 Unpin이 됩니다). 둘째, (있다면) 소멸자(destructor)는 고정된 참조를 받아야 합니다:
impl Drop for Foo {
fn drop(&pinned mut self) {
...
}
}
이는 사용자가 소멸자 안에서 고정된 필드 참조에서 이동하는 것을 방지합니다. 이것이 바로 핀 프로젝션이 비사운드했던 원래 문제였습니다. 반면, 고정되지 않은 필드(예: baz)는 소멸자에서 정상적으로 다룰 수 있습니다. 그 필드는 고정 표시가 없으니까요.
이를 가능하게 하는 요소 중 하나는 Drop::drop 메서드를 직접 호출할 수 없다는 점입니다. 따라서 pinned 필드를 추가해 명시적으로 동의(opt-in)한 타입에 대해서만 drop 메서드의 시그니처를 고정된 참조로 바꾸는 것이 괜찮습니다. 새로운 종류의 Drop 트레이트를 추가할 필요는 없고, 같은 메서드를 다른 시그니처로 사용할 수 있게 하면 됩니다.
이 기능들의 나열을, Join future를 이 기능들로 구현하는 예시로 마무리하고자 합니다. 먼저, 도우미 MaybeDone을 사용하겠습니다. 이는 future 자체이거나(아직 진행 중) future가 완료되었다면 그 결과를 나타냅니다:
enum MaybeDone<F: Future> {
Polling(pinned F),
Done(Option<F::Output>),
}
impl<F: Future> MaybeDone<F> {
// future로 고정 프로젝션을 수행해 poll한 뒤,
// 완료되었다면 self를 재대입합니다:
fn maybe_poll(&pinned mut self, cx: &mut Context<'_>) {
if let MaybeDone::Polling(fut) = self {
if let Poll::Ready(res) = fut.poll(cx) {
*self = MaybeDone::Done(Some(res));
}
}
}
// 이 불변 메서드는 고정된 참조에서도 호출할 수 있습니다:
fn is_done(&self) -> bool {
matches!(self, &MaybeDone::Done(_))
}
// 출력으로의 고정되지 않은 프로젝션을 수행한 뒤
// 그 자리에서 이동(move)합니다:
fn take_output(&pinned mut self) -> Option<F::Output> {
if let MaybeDone::Done(res) = self {
res.take()
} else {
None
}
}
}
보시다시피 pinned 키워드는 세 번만 명시적으로 나타납니다. 어떤 필드가 고정 프로젝션을 지원하는지 선언할 때와, 메서드가 고정 메서드임을 선언할 때입니다. 코드 본문 전반에서, 컴파일러는 이러한 어노테이션을 바탕으로 일반 참조를 지원하듯이 고정된 참조를 통한 프로젝션, 재대여, 대입을 지원합니다.
MaybeDone은 고정되지 않은 필드의 좋은 예시도 제공합니다. 즉, Done 변형의 Option<F::Output> 필드는 고정되지 않습니다. 나중에 이 출력을 이동해 꺼내야 하기 때문입니다. 만약 그 출력이 자기 참조(self-referential) 타입이라면, take_output에서 이동할 계획이므로 즉시 고정 타입 상태에 들어가게 하고 싶지 않을 것입니다.
이제 이 도우미로 Join을 구현해봅시다:
struct Join<F1: Future, F2: Future> {
pinned fut1: MaybeDone<F1>,
pinned fut2: MaybeDone<F2>,
}
impl<F1: Future, F2: Future> Future for Join<F1, F2> {
type Output = (F1::Output, F2::Output);
fn poll(&pinned mut self, cx: &mut Context<'_>) -> Poll<Self::Output> {
// 두 future를 poll합니다:
self.fut1.maybe_poll(cx);
self.fut2.maybe_poll(cx);
// 둘 다 완료되었다면 결과를 가져옵니다:
if self.fut1.is_done() && self.fut2.is_done() {
let res1 = self.fut1.take_output().unwrap();
let res2 = self.fut2.take_output().unwrap();
Poll::Ready((res1, res2))
} else {
Poll::Pending
}
}
}
여기서도 pinned가 명시적으로 나타나는 곳은 필드 어노테이션과 메서드 수신자뿐입니다. 컴파일러는 이를 이용해 Join을 안전하게 그 필드들로 프로젝션할 수 있도록 하며, 우리는 핀이 전혀 없다고 가정하고 구현한 것과 매우 비슷한 코드로 이 근본적인 조합기를 구현할 수 있습니다.
최근에 Move 트레이트 아이디어가 (과거와는 다른 정의로) 다시 제안되었습니다. 이 새 정의에서는 Move를 구현하지 않는 타입은 항상 고정된 타입 상태에 있습니다. 즉, 그것이 존재하는 순간부터 이동할 수 없습니다. 이 아이디어에 대해 더 읽으려면 여기를 보세요.
개념적으로, 저는 이 제안이 잘못된 방향이라고 봅니다. “고정됨”은 가변성이 그러하듯, 타입 의 속성보다는 장소 의 속성으로 표현하는 것이 가장 적절하다고 믿기 때문입니다. 반면에 “고정 가능함(pinnable)”(즉, 고정 타입 상태가 의미를 갖는다는 사실)은 타입의 속성이 맞습니다. 이 구분을 표현하는 데 핀은 매우 적절합니다. 고정된 장소와, 고정된 장소에 의미가 없도록 옵트아웃하는 Unpin 트레이트가 있기 때문입니다. 이는 예를 들어 Copy 트레이트의 동작과 매우 유사합니다. “이동됨”은 장소의 속성이지만, 이동 제약에서 옵트아웃하는 것은 어떤 타입들이 구현하는 Copy라는 타입의 속성입니다.
“고정됨”을 타입의 속성으로 표현하려면, 핀의 상태성을 에뮬레이트하기 위해 두 타입이 필요합니다. 하나는 Move를 구현하는 타입(고정되기 전 객체의 타입)이고, 다른 하나는 그렇지 않은 타입(고정되는 순간 변환되는 타입)입니다. 이는 언어의 네이티브 기능으로 만들 수 있는 것을 라이브러리 코드로 에뮬레이트하면서 생기는 추가 복잡성입니다. 또한 더 많은 언어 기능을 필요로 합니다. 객체를 한 타입에서 다른 타입으로 변환하는 일반적인 방법은 함수를 호출하는 것인데, 함수에서 객체를 반환하는 행위는 이동입니다. 반환하려는 타입이 이동 불가능해야 한다면, 이는 불가능하죠. 그래서 이런 방식의 고정 타입 상태 모델은 또 다른 요구사항을 도입합니다. 바로 배치 생성(emplacement)입니다. 객체를 메모리의 최종 위치에서 그 자리에서 생성하는 방식이죠. 과거에도 Rust에 배치 생성을 추가하려는 시도가 있었지만 성공적이지 못했습니다.
물론 이 제안의 가장 큰 문제는 엄청난 하위 호환성 파괴입니다. 제가 이전 글에서 썼듯이, 값을 이동하는 능력을 제어하기 위해 새로운 자동 트레이트(auto trait)나 새로운 ?Trait를 추가하는 것은 하위 호환적이지 않습니다. 이는 어떤 그러한 트레이트를 추가해야 하는 제안이라면 아마도 풀 수 없는 어려운 문제일 것입니다. 그리고 이런 새로운 방식의 고정 타입 상태 표현은, 핀을 통해 고정 타입 상태를 표현하는 현존하는 Rust의 async 생태계와 완전히 양립 불가능합니다. 제게는 너무나도 쓰기 어려운 약처럼 보입니다.
과거 제 블로그 글에서, 저는 Rust가 Move 트레이트를 재고해보는 아이디어에 어느 정도 동정적이었습니다. 그러나 고정된 참조를 일반 참조처럼 언어에 통합하면 무엇이 가능한지 실제로 글로 써 내려가기 전까지는 그랬습니다. 이제 이러한 변경 세트를 실제로 써보고 핵심 개념을 더 세밀하게 분석해 보니, 훨씬 더 나은 접근은 “고정된 장소”라는 개념을 언어에 통합하는 것이라고 생각하게 되었습니다.
이 글의 모든 내용은 Rust에 완전히 하위 호환적으로 추가할 수 있고, 현재 Pin API를 사용하는 모든 코드와도 완전히 상호 운용됩니다. 이를 위해서는 pinned 키워드를 한 에디션에서 예약해야 합니다(표준 라이브러리 API에 “pin”이 이미 있으므로 “pin” 대신 “pinned”를 선택했습니다). 흥미로운 이점이 하나 더 있습니다. pinned mut라는 토큰 시퀀스는 현재 Rust에서 결코 유효하지 않습니다. 따라서 프로젝트는 맥락적 키워드(contextual keyword)를 사용해 에디션 없이도 고정된 가변 참조와 관련된 기능 전부를 추가할 수 있습니다. 고정된 참조의 주요 사용 사례가 고정된 가변 참조라는 점을 감안하면 꽤 멋집니다. 그러면 어느 포인트 릴리스에서든 추가할 수 있고, 기존 async 생태계와의 호환성을 깨지 않으면서 코드가 새 관용구로 옮겨갈 수 있습니다. 이후 다음 에디션에서는 훨씬 덜 사용되는 고정된 불변 장소와 참조를 지원하기 위해 pinned를 비맥락적 키워드로 만들 수 있습니다.
마지막으로, 위에 링크한 글에는 Move 트레이트 추가 외에도 아이디어가 담겨 있습니다. 특히 안전한 자기 참조 타입을 Rust에 추가하는 아이디어가 있습니다. 저는 그 아이디어를 면밀히 검토하지 않았고 이에 대해 어떤 의견도 표명하지 않습니다. 요지는, 일단 자기 참조 값을 가지게 되면 그것은 고정된 타입 상태에 있어야 한다는 것입니다. 이를 Move 트레이트를 구현하지 않게 만드는 방식으로 제공하는 대신, 제 견해로는 더 하위 호환적이고 이론적으로 건전한 방식은 그것이 고정된 장소에 존재하도록 요구하고 Unpin을 구현하지 않게 하는 것입니다.
저는 설계 공간에 Pin보다 더 나은 설계가 있다고 생각합니다. 다만 현재의 Rust와는 전혀 하위 호환적이지 않습니다. (그리고 아마도 작동하지 않을 수도 있습니다. 저는 이 아이디어를 구현해 본 적이 없으니까요!) 염두에 둘 가치는 있지만, Rust가 실제로 채택할 수 있는 것이라 보지는 않습니다.
자기 참조 구조체 문제를 풀려 했을 때, Aaron Turon은 가변 참조가 “너무 강력하다”고 표현했습니다. 가변 참조는 항상 값에 대입할 수 있을 뿐 아니라 그 자리에서 이동할 수도 있게 해 주기 때문입니다. 기본적으로 장소가 디폴트로 고정(혹은 “이동 불가능”)되어 있고, 그 자리에서 이동을 지원하도록 옵트인해야 하는 대안을 상상해볼 수 있습니다. 이렇게 하면, 기본적으로 장소는 최소한의 권한(공유 참조로만 접근 가능)만 갖고, 점차 권한을 얻는(대입 가능, 이동 가능) 단조 증가 구조가 됩니다.
장소가 이동을 옵트인해야 하는 것에 더해, 참조 타입이 둘이 아니라 셋이라고 상상해 볼 수 있습니다. 불변, 가변, 그리고 이동 가능한 참조입니다. mem::swap이나 Option::take 같은 API는 세 번째 종류의 참조를 받게 됩니다. 객체를 제자리에 고정하기 위해 오직 가변 참조만 제공하는 어떤 래퍼 타입이 있을 것입니다. 그렇다면 위의 MaybeDone처럼 “가변이지만 이동 불가능한 참조”에서 이동하고 싶을 때는 어떻게 지원해야 할까요? Rust가 공유 참조를 통한 값 변경을 처리하는 방식과 같습니다. 즉, “내부 가변성”이 있듯 “내부 이동 가능성” 같은 기능을 두는 것입니다.
언뜻 이런 설계가 Rust가 도달한 지점보다 더 우아하고 내부적으로 일관돼 보이기는 합니다. 그렇다고 해서 기존 언어와의 호환성을 깨면서까지 채택할 만큼 우월하다고는 생각하지 않습니다. Future와 Stream에 대한 사용자 정의 조합기의 필드는, 고정 프로젝션을 수행하고자 할 때 고정 한정자를 가져야 할 것입니다. 그러면 되는 겁니다. 하위 호환성이라는 제약을 고려하면, Pin을 언어에 통합하고 나면 충분히 쓸 만한 기능이 되고, 현재의 어려움은 기존의 활발한 async Rust 생태계와의 호환성을 잃지 않으면서 쉽게 해결될 수 있다고 봅니다. 우리는 우리가 살고 있는 모든 맥락과 우리 이전에 있던 것들의 맥락 안에 존재합니다.