UnpinCell 개념을 통해 핀 고정된 Generator 트레이트를 도입하고 for 루프를 포함한 Iterator 기반 인터페이스와의 브리지, 일관성(coherence) 문제, 역방향 브리징 방안을 논의한다.
7월에, 나는 핀 고정(Pin)을 언어에 더 깊이 통합해 더 사용하기 쉽게 만드는 방법을 설명했다. 지난주에는 그 아이디어를 UnpinCell이라는 개념으로 더 발전시켰다. UnpinCell은 사용자가 &pin mut UnpinCell<T>를 받아 &mut T를 만들어낼 수 있게 해주는 래퍼 타입으로, 다른 셀들이 셀에 대한 공유 참조에서 내부에 대한 가변 참조를 만들어내는 것과 비슷하다. 이 개념은 제네레이터가 직면한 가장 큰 미해결 과제, 즉 Iterator 인터페이스가 자기 참조 값을 허용하지 않는다는 사실을 해결할 수 있다고 믿는다.
내가 Pin의 설계를 설명하면서 썼듯이, Pin이 다른 설계 아이디어들에 비해 가졌던 가장 큰 장점은 “객체가 결코 이동되지 않는다”는 계약을 이전 코드와 쉽게 호환되는 방식으로 도입할 수 있었다는 점이다. 하지만 이는 트레이트가 그 계약에 동의하려면 새로운 인터페이스를 사용해야 한다는 뜻이기도 했다. Pin 이전에 존재했고 그 계약에 동의하지 않는 트레이트는 자기 참조 값을 갖는 타입에 대해 구현할 수 없다. 여기서 가장 문제적인 트레이트가 Iterator다. 제네레이터(비동기 함수가 future를 내놓는 것과 같은 방식으로 반복자를 내놓는 함수)는 이상적으로 비동기 함수처럼 자기 참조 값을 지원해야 한다. 그러나 Iterator의 인터페이스가 핀 고정된 가변 참조가 아니라 일반 가변 참조를 받는 한, 구현자는 그 반복자가 이동될 수 있다고 가정해야 하고, 따라서 자기 참조적일 수 없다.
가능한 해법 중 하나는 핀 고정된 새로운 트레이트를 추가하는 것이다. 여기서는 이를 Generator라고 부르겠다(다른 이름도 가능하지만 여기서는 논하지 않겠다). Iterator와 마찬가지로 IntoGenerator라는 유사한 트레이트와 쌍을 이루며, 모든 제네레이터는 모든 반복자가 IntoIterator를 구현하듯이 IntoGenerator를 구현한다:
trait Generator {
type Item;
fn next(&pin mut self) -> Option<Self::Item>;
}
trait IntoGenerator {
type Item;
type IntoGen: Generator<Item = Self::Item>;
fn into_gen(self) -> Self::IntoGen;
}
impl<T: Generator> IntoGenerator for T {
type Item = T::Item;
type IntoGen = T;
fn into_gen(self) -> T {
self
}
}
문제는 반복자에서 제네레이터로 어떻게 브리지하느냐다. 조합기(combinator)에 대해서는 해결이 간단하다. Generator가 Iterator처럼 다양한 제공 메서드를 가지면 된다. 표준 라이브러리 안의 코드를 일부 중복하게 되지만, 이는 사용자에게 큰 영향을 주지 않고 표준 라이브러리 유지보수자에게만 부담을 준다.
더 큰 문제는 for 루프를 어떻게 할 것이냐이다. 현재 for 루프는 IntoIterator를 구현한 모든 타입을 순회할 수 있다. 이를 IntoGenerator를 구현한 모든 타입을 지원하도록 바꾸려면 어떻게 해야 할까? UnpinCell을 사용하는 다음 구현이 하나의 해법이 될 수 있다:
impl<T: IntoIterator> IntoGenerator for T {
type Item = T::Item;
type IntoIter = UnpinCell<T::IntoIter>;
fn into_gen(self) -> UnpinCell<T::IntoIter> {
UnpinCell::new(self.into_iter())
}
}
impl<T: Iterator> Generator for UnpinCell<T> {
type Item = T::Item;
fn next(&pin mut self) -> Option<T::Item> {
self.inner.next()
}
}
반복자를 UnpinCell로 감싸면, 사용자는 Generator가 제공하는 핀 고정된 참조를 통해 원래는 일반(비핀) 가변 참조를 기대하는 next 메서드를 호출할 수 있다. 덕분에 IntoIterator에서 IntoGenerator로의 브리지가 쉬워진다. for 루프의 디슈가링은 다음과 같이 바뀔 것이다:
let mut iter = IntoIterator::into_iter($collection);
while let Some($elem) = Iterator::next(&mut iter) {
$body
}
에서 다음으로:
let pin mut gen = IntoGenerator::into_gen($collection);
while let Some($elem) = Generator::next(&pin mut gen) {
$body
}
이 변경은 이전과의 호환성을 유지한다. 위의 브리지 구현 덕분에 모든 IntoIterator 타입은 자동으로 IntoGenerator 타입이 되기 때문이다. 이제 for 루프는 자기 참조 제네레이터를 처리할 수 있다.
한 가지 문제가 있다. 방금 제시한 impl 집합은 일관적(coherent)이지 않다. 구체적으로, 다음 두 impl이 겹친다:
impl<T: Generator> IntoGenerator for T {
type Item = T::Item;
type IntoGen = T;
fn into_gen(self) -> T {
self
}
}
impl<T: IntoIterator> IntoGenerator for T {
type Item = T::Item;
type IntoIter = UnpinCell<T::IntoIter>;
fn into_gen(self) -> UnpinCell<T::IntoITer> {
UnpinCell::new(self.into_iter())
}
}
Generator와 IntoIterator를 둘 다 구현하는 타입은 어떻게 할 것인가? 어떤 해법을 찾아야 한다.
하나의 해법은 불안정 기능인 negative impl(또는 컴파일러의 특수 처리를) 확장하여, 동일한 타입에 대해 IntoIterator와 Generator를 둘 다 구현하는 것을 불법으로 만드는 것이다:
impl<T: Generator> !IntoIterator T { }
impl<T: IntoIterator> !Generator for T { }
아마도 이것이 문제를 해결하는 가장 쉽고 현실적인 방법일 것이다. 동일한 타입에 두 impl을 모두 허용하면서 IntoGenerator의 impl이 올바른 것을 고르도록 하는 방식은, 지금까지 특수화를 막아온 수명 비매개변성(non-parametricity) 문제와 동일한 문제를 야기할 것처럼 보인다.
하지만 이렇게 하면 역방향 브리징 문제, 즉 제네레이터를 Iterator 또는 IntoIterator를 기대하는 인터페이스에 어떻게 전달할 것인지가 남는다. 생태계에는 그런 인터페이스가 수두룩하다.
일반적으로, 제네레이터를 반복자로 취급하는 것은 안전하지 않다. 반복자는 각 반복 사이에 이동될 수 있고, 제네레이터는 자기 참조적일 수 있기 때문이다. 따라서 그렇게 취급하기 전에 제네레이터를 스택이나 힙에 핀 고정해야 한다:
impl<T: Generator + ?Sized> Iterator for &pin mut T {
type Item = T::Item;
fn next(&mut self) -> Option<T::Item> {
(*self).next()
}
}
impl<T: Generator + ?Sized> Iterator for Pin<Box<T>> {
type Item = T::Item;
fn next(&mut self) -> Option<T::Item> {
(*self).next()
}
}
하지만 제네레이터가 Unpin을 구현한다면, 핀 고정하지 않고도 Iterator를 구현해도 사실 문제없다. 예를 들어 다음을 추가하고 싶어질 수 있다:
impl<T: Generator + Unpin + ?Sized> Iterator for T {
type Item = T::Item;
fn next(&mut self) -> Option<T::Item> {
self.next()
}
}
그러나 이렇게 하면 T: Generator + Unpin이(반복자에 대한 포괄 impl을 통해) IntoIterator를 구현하게 되어, 앞서 논의한 일관성 해법—제네레이터는 절대 IntoIterator를 구현할 수 없다는 규칙—과 충돌하게 된다.
내가 떠올릴 수 있는 유일한 해법(사용자에게 드러나는 어려움 없이 모든 것이 일관적이 되도록 특수화를 충분히 활성화하는 방법을 제외하면)은, 사용자가 반복자를 기대하는 인터페이스에 제네레이터를 넘기고 싶을 때 명시적으로 호출해야 하는 래퍼 타입을 제공하는 것이다. 최선은 아니지만, 사용자 측 영향은 이제 새로운 제네레이터를 오래된 반복자 기반 인터페이스에 넘기려는 경우로 제한된다.
가능한 경우, 이러한 오래된 인터페이스는 보통 반복 도중 반복자를 이동하지 않으므로, 이전과의 호환성을 유지하면서 IntoIterator 대신 IntoGenerator를 받도록 업그레이드할 수 있다. 사용 중인 라이브러리가 아직 업그레이드되지 않았다면, 사용자는(제네레이터가 Unpin이 아니라면) 간접화를 사용하거나(또는 표준 라이브러리가 제공할 수도 있는) 래퍼 타입을 사용할 수 있다.
내 생각에, 고정되지 않은 인터페이스에서 핀 고정 인터페이스로 사용자를 전환시키는 것과, 신식 제네레이터를 구식 라이브러리에 전달할 때 약간의 어려움이 생기는 정도의 사용자 영향은 충분히 감내할 만하다. 특히 가변성, 소유권, 비동기, 반복 같은 Rust의 핵심 모델을 대대적으로 바꾸는 대안 제안과 비교하면 더욱 그렇다. 사용자 입장에서, 나는 Rust 프로젝트가 현재 존재하는 언어의 사용자 경험을 다듬는 점진적 개선을 제공하는 데 집중하길 바란다.