Iterator를 Pin이 필요한 새 Generator 트레이트로 바꾸는 가상의 전환을 가정하고, 브리지 impl과 코히어런스, pinning과의 상호작용, FromIterator/Extend 전환, for 루프 디슈가링, 그리고 기술적·사회적 비용을 검토한다.
지난 한 달 동안 여가 시간의 상당 부분을 구조적 동시성에 대해 생각하는 데 썼고, 그에 대한 블로그 글도 곧 올릴 예정이지만, 그에 앞서 이터레이터와 제너레이터를 다시 짚고자 한다.
이전 글에서(https://without.boats/blog/generators) 나는 제너레이터의 가장 어려운 문제 중 하나인 자기참조 제너레이터(self-referential generators)에 대해 썼다. 우리가 async 함수를 설계할 때의 Future 트레이트와 달리, Iterator 트레이트는 이미 안정화되어 있고, 자기 자신에 대한 Pin된 참조를 받지 않는다. 이는 Iterator가 자기참조일 수 없음을 의미한다.
이전 글에서, 나는 Iterator가 핀(핀 처리된) 참조를 받았어야 한다고 생각한다고 말했고, 그것이 그렇지 않다는 사실에 대해 선택지가 세 가지 있다고 적었다:
Iterator를 사용 중단(deprecate)하고 에코시스템을 새로운 트레이트로 옮긴다.그때 나는 첫 번째 선택지가 최선이라고 썼고, 지금도 그쪽에 무게를 둔다. 주된 이유는 비파괴적이고, 즉시 배포 가능하며, 만약 불충분하다고 판명되면(예: 자기참조 제너레이터를 위한 새로운 수정자(modifier)를 추가하는 식으로) 문제를 “수정”하는 데 전방 호환적이기 때문이다. 하지만 대안들을, 특히 세 번째 대안을 더 철저히 탐색해 보고 싶다.
그래서 질문해 보자: 에코시스템을 Iterator에서 새로운 트레이트로 옮기면 어떤 모습일까? 이 글에서는 새 트레이트를 Generator라고 부르자. Iterator와 동일한 인터페이스를 가지되, 단 하나 다른 점은 pinning이 필요하다는 것이다:
trait Generator {
type Item;
fn next(self: Pin<&mut Self>) -> Option<Self::Item>
}
마찬가지로, IntoIterator에 해당하는 것도 필요할 텐데, 이를 IntoGenerator라고 부르자:
trait IntoGenerator {
type Item;
type IntoGen: Generator<Item = Item>;
fn into_gen(self) -> Self::IntoGen;
}
그리고 이 둘을 바탕으로, for 루프의 디슈가링은 이터레이터/제너레이터를 생성한 뒤, 루프가 시작되기 전에 그것을 pin 하도록 바뀔 것이다. 여기까지는 잘 굴러간다. 문제는 이미 존재하는 모든 것과의 하위 호환성을 유지하려 할 때 드러난다.
브리지 impl이 필요할 것이다. 즉, 모든 이터레이터가 이제 제너레이터이기도 해야 한다. 마찬가지로 모든 IntoIterator가 IntoGenerator이기도 해야 한다.
문제는 모든 Iterator가 동시에 IntoIterator이기도 하고, 아마도 모든 Generator 역시 IntoGenerator이기도 해야 한다는 점에서 발생한다(오늘날 그 impl이 존재하는 이유와 동일하다: for 루프에 Generator와 IntoGenerator를 모두 전달할 수 있도록). 그리고 이것은 기본적인 다이아몬드 코히어런스 문제를 만든다:
impl<T: Iterator> IntoIterator for T { }
Impl<T: Iterator> Generator for T {}
Impl<T: IntoIterator> IntoGenerator for T {}
impl<T: Generator> IntoGenerator for T {}
두 경로 중 어느 경로를 통해 Iterator가 IntoGenerator를 구현하게 되는가?:
┌──────────────────┐
│ │▒▒
┌─────│ Iterator │─────┐
│ │ │▒▒ │
│ └──────────────────┘▒▒ │
▼ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▼
┌──────────────────┐ ┌──────────────────┐
│ │▒▒ │ │▒▒
│ IntoIterator │▒▒ │ Generator │▒▒
│ │▒▒ │ │▒▒
└──────────────────┘▒▒ └──────────────────┘▒▒
▒▒▒▒▒▒▒▒│▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒│▒▒▒▒▒▒▒▒▒▒▒
│ ┌──────────────────┐ │
│ │ │▒▒ │
└────▶│ IntoGenerator │◀────┘
│ │▒▒
└──────────────────┘▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
다행히도, 이 impl들은 동일한 코드 생성으로 이어지지만, 어쨌든 이 문제는 해결되어야 한다. 아마도 컴파일러가 이러한 impl들에 대해 특수 케이스 코히어런스 예외를 둘 수 있을 것이다.
pinning 변화로부터 또 다른 문제가 생긴다. Iterator에 대한 Generator의 impl을 실제로 들여다보자:
impl<I: Iterator> Generator for I {
type Item = <I as Iterator>::Item;
fn next(self: Pin<&mut Self>) -> Option<Self::Item> {
// Is this sound?
unsafe { Iterator::next(Pin::get_unchecked_mut(self)) }
}
}
Iterator::next를 호출하려면, pin 해제된 가변 참조가 필요하다(결국 이것이 Iterator의 전체 문제다). 하지만 어떤 Iterator는 !Unpin일 수 있으므로, 그에 접근하는 유일한 방법은 참조를 안전하지 않게(unafe하게) unpin하는 것이다. 저 코드는 사운드한가?
처음에는 답이 아니라고 느껴질 것이다. next 메서드에서 자기 자신에서 값을 이동(move)시키는 Iterator를 정의하는 것은 충분히 상상 가능하고, 동시에 그 타입이 !Unpin이면서, 그 사실에 의존하는 다른 pin-필요 메서드들을 가질 수도 있다. 하지만, 곰곰이 생각해 보면, 그런 메서드는 필연적으로 unsafe 코드를 포함하게 될 것이고, 따라서 러스트 팀은 그 코드가 비사운드함을 내포한다고 선언할 수 있을 것이다.
이는 Drop의 상황과 유사하다: Drop 역시 사실상 pin되어야 마땅하지만, 그렇게 하지 않았기 때문에, 우리가 한 일은 만약 drop 중에 move를 수행한다면, 그 타입이 pin 보장에 의존하도록 만드는 책임이 전적으로 당신에게 있다는 것을 선언하는 것이었다. 당신의 타입이 Iterator를 구현한다면 유사한 요구 사항을 부과할 수 있을 것이다.
하지만 사후적으로 이러한 요구를 부과하는 것은 기술적으로 호환성 파괴 변경(breaking change)이라는 점을 유념해야 한다. Pin의 사운드니스 요구 사항을 더 엄격하게 제한하기 때문이다. 다만 이에 위배되는 코드는 전적으로 병리적일 것이라고 나는 믿고, 이는 과거에 언어 팀이 수용해 온 “기술적으로는 깨지지만 사운드니스를 위한” 변경의 정확한 유형이기도 하다.
아마도 핵심적인 차이는, 과거의 그러한 변경은 기존 기능의 구멍을 메우기 위한 것이었지만, 여기서는 새로운 기능을 지원하기 위해 규칙을 바꾸는 일이라는 점일 것이다. 팀은 그 방향으로 얼마나 나아갈지에 대해 매우 신중해야 한다.
지금까지 우리는 특수 케이스 코히어런스 예외 하나와 기술적으로는 호환성 파괴인 사운드니스 변경 하나를 쌓았다. 여기서는 팀이 이 전환을 완전히 매끄럽게 만들 수 없고, 라이브러리들이 제너레이터와 호환되도록 각 저자들이 업데이트해야만 한다고 생각하는 지점을 이야기하겠다.
FromIterator와 Extend는 모두 시그니처에 IntoIterator 인터페이스를 갖는다. 제너레이터는 Iterator나 IntoIterator를 구현하지 않으므로, 새로운 트레이트가 필요하다: 이를 FromGenerator와 Grow라고 부르자. 이들은 FromIterator와 Extend와 동일하지만, IntoGenerator를 받는다는 점만 다르다.
문제는 FromIterator에서 FromGenerator로, 또는 Extend에서 Grow로의 블랭킷 impl이 불가능하다는 점이다. IntoGenerator를 IntoIterator를 기대하는 함수에 전달할 방법이 없기 때문이다. 어떤 FromIterator 인터페이스가 next 호출 사이에 이터레이터를 이리저리 이동시킬 가능성은 충분히 있으며, 그렇다면 pinning 요구 사항을 위반하게 된다.
나는 조사를 하지는 않았지만, 표준 라이브러리에서 FromIterator와 Extend를 impl하는 어떤 타입도 제너레이터 상응물(generator equivalents)을 구현하지 못한다면 매우 놀랄 것이다. 하지만 서드 파티 라이브러리는 제너레이터와 호환되기 위해 새로운 트레이트의 구현을 수동으로 추가해야 한다.
Reddit의 Giacomo Stevananto와 Twitter의 Steven Portzer가 실제로 여기서 블랭킷 impl을 쓰는 방법이 있다고 지적해 주었다.
Pin<&mut impl Generator>에 대해 Iterator의 블랭킷 구현이 아마 있어야 한다 — 제너레이터는 이터레이터가 아니지만, 제너레이터에 대한 핀된 참조는 _이터레이터_다. 이를 이용하면, FromGenerator에 대한 FromIterator의 블랭킷 impl과, Grow에 대한 Extend의 블랭킷 impl을 먼저 제너레이터를 pin한 다음 해당 인터페이스에 전달하는 방식으로 구현할 수 있다.
나는 이것이 기술적 비용의 완전한 개요라고 생각하지만, 그보다 더 중요한 것은 사회적 비용이라고 본다. 이터레이터에 대한 모든 기존 문서(핵심 언어 기능)가 구식이 될 것이다. 가능하다면 업데이트되어야겠지만, 업데이트되지 않은(인쇄물 포함) 방대한 문서들이 여전히 존재할 것이고, 그것들은 그냥 틀리게 된다. 모든 Rust 사용자는 새로운 트레이트와 전환에 대해 배우고, 그것을 감당해야 한다. 미래에는 새 사용자들이 문서와 오래된 코드에서 여전히 옛 인터페이스를 접하게 될 것이고, 전환과 그에 대한 대처법을 배워야 한다.
이는 2018 에디션 이후 가장 큰 변화, 어쩌면 그보다 더 큰 변화가 될 것이며, Rust의 기존 사용자는 2018년에 비해 수십 배로 많다. 그만한 가치가 있을까? 아마도. 문제는 현재의 데이터로는 자기참조 제너레이터가 얼마나 중요한지 가늠하기가 정말 어렵다는 점이다.
나를 망설이게 하는 것은, 팀이 공개적으로 검토하는 이런 사회적으로 거대한 전환이 이것뿐만이 아니라는 점이다. 새로운 추상화 축을 도입하자는 제안을 제쳐 두더라도, Leak 트레이트 도입 같은 이야기들이 있었다. 이러한 종류의 변경은 기술적으로는 프로젝트의 안정성 보장을 충족할 수 있을지 모르지만 여전히 극도로 파괴적이며, 반복적으로 이루어질 수는 없다. 그렇지 않으면 커뮤니티의 신뢰와 언어의 외부 평판을 훼손하게 될 것이다.