Rust에서 제너레이터를 통해 명령형 반복 코드를 쉽게 작성하고 비동기성과 실패 가능성과의 조합을 단순하게 만들자는 제안. 문법(선언, yield from, 반환 타입)과 의미론(반환/?, 자기 참조) 쟁점을 정리하고, 어떤 기본 선택을 채택할지에 대한 견해를 제시한다.
최근 글에서 강조해 온 핵심 중 하나는, 제너레이터를 출시하면 명령형 반복 코드를 쉽게 작성할 수 있게 되어 많은 사용자 문제가 풀리고, 특히 그 반복 코드가 비동기성과 실패 가능성과도 잘 상호작용하도록 만들 수 있다는 점이다. 답답한 점은, 제너레이터는 수년 전부터 거의 출시 직전 상태였는데도 가시적인 진전이 매우 적었다는 것이다. 특히, 제너레이터를 상태 머신으로 변환하는 핵심 컴파일러 변환은 이미 존재하는데, 이는 바로 async 함수가 구현되는 방식과 동일하기 때문이다.
제너레이터를 출시하기 전에 내려야 할 모든 결정들을 모으고, 그에 대한 내 의견을 정리하고자 한다. 2019년에 나는 제너레이터에 관한 글을 두 편(첫 번째, 두 번째) 썼고, 2020년에는 남은 설계 이슈 몇 가지에 대한 내 해법을 구현한 propane이라는 라이브러리를 만들었다. 2021년 초에는 Rust 프로젝트의 한 구성원과 제너레이터 작업을 이어받겠다는 취지로 대화를 나누기도 했지만, 그 대화의 결과로 공개된 작업을 보지는 못했다. 아쉽게도, 이 글은 2019년과 2020년 글의 재탕이 될 부분이 많은데, 그동안 크게 바뀐 것이 없기 때문이다.
예전에 했던 말을 다시 강조하고 싶다. 내가 Rust에서 조만간 보고 싶은 기능은, 이터레이터로 컴파일되는 함수를 명령형 문법으로 작성하는 것이다. 나는 이것을 "제너레이터"라고 부른다. 같은 컴파일러 변환으로 더 일반적인 기능도 지원할 수 있는데 — 일시 중지와 재개가 가능한 "재개 가능한(resumable) 함수"로서의 상태 머신 — 나는 이를 "스택리스 코루틴(stackless coroutine)"이라고 부르는 편이 좋다. 큰 차이는 스택리스 코루틴은 일시 중지와 재개 사이에서 값을 반환하고 또 입력받을 수 있어 더 유연하다는 점이다. 그러나 이는 곧 순수한 반복(iteration)에는 딱 맞지 않고, 해결해야 할 다른 설계 이슈도 있다는 뜻이기도 하다. 스택리스 코루틴이 나쁜 기능이라고 생각하지는 않지만, 이터레이터를 위한 제너레이터라는 더 좁은 기능과 동일한 우선순위를 받아서는 안 된다고 생각하며, async 함수의 경우와 마찬가지로 이들 기능의 문법도 서로 달라야 한다고 본다.
혼란스러운 지점 하나는, 더 일반적인 스택리스 코루틴 메커니즘을 위한 불안정 트레이트의 이름이 Generator라는 것이다. 내 의견으로는, 이 트레이트의 이름을 바꾸거나, 이터레이터를 반환하는 함수를 부르는 새로운 이름을 누군가 마련해야 한다. 이 글의 나머지 부분에서는 "제너레이터"라는 말을 이터레이터를 반환하는 함수라는 의미로 사용하겠다.
현재의 불안정 제너레이터 기능은 아마도 제너레이터가 가져야 할 문법과 정확히 일치하지 않는다. 우선, 현재는 클로저만 제너레이터로 지원한다. 이는 스택리스 코루틴 기능에는 그럴듯할 수 있지만, async 함수가 있듯이 사용자들은 제너레이터 함수도 정의하고 싶어 할 것이다. 또 다른 큰 문제는, 명시적인 선언 문법이 없다는 점이다. 클로저 안에 yield 문이 있으면 그것이 제너레이터이고, 그게 전부다.
대신, async처럼 함수와 블록 모두에 적용할 수 있는 문법이 있어야 하며, 이를 적용하면 해당 코드에 제너레이터 변환을 수행하여 정상 평가 대신 이터레이터로 평가되도록 해야 한다. 이 문법을 무엇으로 할지에 대한 다양한 아이디어가 있었지만, 가장 단순하고 직관적인 것은 async가 동작하는 방식과 정확히 같은 형태로 fn이나 블록 앞에 놓는 키워드를 고르는 것이라고 생각한다. 내게 가장 자명한 키워드는 gen이지만, 다른 후보가 있을 수도 있고 기능의 명칭에도 좌우될 수 있다.
안타깝게도, 2018 에디션에서 async를 예약했던 것과 달리 2021 에디션에서는 제너레이터용 키워드가 예약되지 않았다. 이는 적어도 2024 에디션 전까지는 제너레이터에 적절한 키워드가 없다는 뜻이다. 어떤 사용자들은 "raw keywords" 기능을 이용해 그래도 안정화할 수 있다고 제안했다. 예를 들어 k#gen 같은 문법으로 안정화할 수 있다는 것이다. 개인적으로, raw 키워드는 구 에디션에서 새 기능을 쓸 수 있게 해준다는 점에서 좋다고 생각하지만, 이 기능이 모든 에디션에서 raw 키워드 문법을 반드시 요구하는 형태로 출시되는 것은 매우 불만족스러울 것이다. 그래서 나는 2024 에디션과 함께, 적절한 키워드를 예약하여 제너레이터를 출시하는 편을 선호한다.
yield from제너레이터에서 항목을 내보내는 키워드는 이미 yield로 거의 정해져 있다. 이는 Rust 2015 에디션 이전부터 예약어였다. 하지만 거의 주목받지 못한 것이 하나 있는데, 바로 "yield from" 연산자의 개념이다. 이는 다른 이터레이터를 받아 그것이 끝날 때까지 그 결과를 그대로 내보내는 표현식이다. 이런 종류의 연산자는 ?나 await와 유사한 "효과 전달(effect-forwarding)" 연산자다.
내 의견으로는, 이 기능은 나중으로 미루거나, 심지어 영원히 구현하지 않아도 된다. 디슈가링이 사소하기 때문이다 — for 루프로 해당 이터레이터를 소비하면서 각 항목을 yield하면 된다. 제어 흐름 효과를 추상화가 아닌 패턴으로 다루는 접근의 유용성이 여기에 드러난다. async와 try 기능은 전달 연산자 없이는 사실상 쓸모가 없지만, 제너레이터에서는 전달 연산자가 그리 중요하지 않다.
async 함수와 마찬가지로, 제너레이터도 어떤 트레이트(Future, Iterator, AsyncIterator)를 구현하는 익명 타입을 반환한다. async 함수의 경우, 이 익명 타입(“바깥” 반환 타입)은 문법에 포함하지 않고, 상태 머신이 내보내는 값의 타입(“안쪽” 반환 타입)만 표기하기로 결정했다. 내 생각에 이는 전적으로 올바른 결정이었지만, 여전히 의구심을 표하는 기여자들이 있고, 제너레이터에도 이 문제를 다시 논의하고 싶어 하는 듯하다.
안쪽 반환 타입만 포함하기로 했던 모든 이유는 제너레이터에도 똑같이 적용된다. 바깥 타입을 표기하면 극도로 장황해진다(예: -> impl Iterator<Item = T> + 'a). 또한 모두가 새 수명 생략 문법을 배워야 하거나, 아니면 이런 함수에서 수명 생략을 쓸 수 없게 된다. 게다가 비중복 정보가 거의 없다. 아주 나쁜 선택이다.
사람들이 거론하는 구체적 이점은, 반환 타입이 Send임을 선언할 문법을 제공한다는 것이다. 안쪽 타입만 표기하기로 했을 때 이미 알려진 이슈였고, 반론을 다시 정리하겠다. impl Trait에는 "auto trait 투명성"이 있기 때문에, 선언 지점이 아니라 사용 지점(특히 어떤 제네릭 문맥의 메서드에서)에서 Send를 걸어야 하는 경우가 여전히 있다. 즉 사용자는 이런 함수들을 사용할 때 반환 타입에 Send 제약을 걸 수단이 필요하다.
사실 내 생각에는, Send 제약은 거의 항상 선언 지점이 아니라 사용 지점에 두고 싶어진다. 따라서 "반환 타입 표기법(return type notation)" 같은 문법이 어차피 필요하다. 주장되는 이점은 선언부에 where 절을 추가하거나, 그 where 절을 추가하는 매크로, 혹은 전용 문법으로도 달성할 수 있다. 그리고 이미 async 함수에 대해 안쪽 반환 타입을 쓰기로 결정되었으므로, 제너레이터만 다르게 하면 일관성만 해치고 명확한 이점은 없다.
이는 "try 함수"에 대한 내 의견과는 다르다는 점에 유의하라. try 함수는 "바깥" 반환 타입을 보여주는 것이 타당한데, 여기서는 거의 모든 요인이 반대 방향이기 때문이다. 바깥 반환 타입이 장황하지 않고, 수명 표기와 충돌하지 않으며, 유용한 정보를 많이 담고 있다(이 함수들은 특정 타입을 반환하고, 선택지도 여러 개이며, impl Trait가 아니다). 다시 말해, 같은 패턴이라도 적용 방식의 차이를 허용하면 가치가 크다.
?먼저 다룰 의미론적 질문은 제너레이터에서 return 표현을 어떻게 처리할지다. 두 가지 경우가 있다. 제너레이터가 끝나서 더 이상 내보낼 것이 없는 경우, 그리고 마지막으로 무언가를 하나 내보내고 나서 끝나는 경우다.
여기서 반환 표현이 여러 타입을 가질 수 있는(즉, ()이거나 제너레이터의 yield 타입이 될 수 있는) 일종의 "폴백" 메커니즘을 두는 것은 좋지 않다고 본다. 이런 폴백은 타입 추론 문제를 낳고 구현을 복잡하게 만든다. 그래서 선택지는 두 가지라고 본다.
return은 타입 ()의 표현식을 받는다.return은 타입 Option<T>의 표현식을 받는다.두 번째의 장점은, 무언가를 내보내고 즉시 반환하고 싶을 때 한 표현식으로 할 수 있다는 것이다. 그러나 단점은 제너레이터가 항상 Option을 반환해야 해서, 예컨대 제너레이터의 마지막 표현식이 꼭 Option<T>가 되어야 한다는 점이다. 결과적으로 제너레이터 끝에 None을 많이 붙이게 될 것이다. 내 생각에는 return이 ()를 받는 편이 낫다. 이러면 그 문제를 피할 수 있고, 더 이상 내보내지 않으려면 yield 다음에 return을 쓰는 편이 더 명확하다.
유일한 문제는 ? 연산자를 어떻게 할 것이냐는 점이다. 제너레이터가 아닌 함수에서 ?는 에러 값을 조기 반환하는 형태로 확장된다. 하지만 제너레이터가 항상 ()를 반환한다면 이 방식은 통하지 않는다. 즉, 제너레이터에서 ?를 쓰려면 디슈가링을 바꿔야 한다. 참고로, 설령 제너레이터가 Option을 반환한다고 해도, 제너레이터에서 에러를 "던질" 수 있게 하고 싶다면 디슈가링을 어쨌든 바꿔야 한다. 왜냐하면 그 경우 반환 표현은 Option<Result<T>>를 받아야 하기 때문이다.
가장 자명한 선택(그리고 내가 propane에서 구현한 방식)은, 제너레이터에서 ?가 에러인 경우 두 단계를 밟도록 디슈가링하는 것이다. 먼저 그 에러를 yield로 내보내고, 그 다음 return으로 이터레이터를 종료한다. return은 ()를 받고, ?는 "yield 후 return"으로 디슈가링되는 이 조합이 내가 propane에서 한 방식이다.
제너레이터의 더 까다로운 문제 — 사실 이 기능의 큰 미해결 과제 — 는 자기 참조(self-reference)의 문제다. 근본적으로 문제는 Iterator::next가 이터레이터 상태에 대한 고정(Pin)된 참조를 받지 않기 때문에, 이터레이터 상태가 자기 참조를 담을 수 없다는 것이다. 이는 상태에 대한 고정된 참조를 받는 Future::poll과 대조적이다. 컴파일러는 이미 자기-참조 가능한 코루틴과 자기-참조가 불가능한 코루틴을 모두 지원한다. 문제는 자기 참조가 유용한 기능이고, Iterator 트레이트는 안정화되어 있으며 이를 지원하지 않는다는 점이다.
한편 AsyncIterator 트레이트는 불안정이며 현재는 상태에 대한 고정 참조를 받기 때문에 자기 참조를 담을 수 있다. 다만 일부에서는 Iterator와의 "일관성"을 높이겠다며, await 지점 사이에서는 자기 참조를 유지할 수 있게 하되 yield 지점 사이에서는 못 하게 만들자는 아이디어를 내놓기도 했다. 이는 AsyncIterator를 async next 메서드로 정의하려는 생각과 맞닿아 있다. 나는 이것이 나쁜 선택이라고 생각하며, 제어 흐름 효과를 완전히 정규화된 추상화로 취급하려는 과도한 집착이 낳는 결함을 보여준다고 본다. 이것은 패턴이고, 불규칙함을 가진다.
자기-참조 가능한 제너레이터 문제를 푸는 방법은 대략 세 가지다.
Iterator를 구현한다.Iterator를 반환하지 않고 다른 무언가를 반환한다. 이 타입은 먼저 고정(Pin)해 제자리에 고정한 뒤에야 Iterator로 바꿀 수 있다.Iterator 트레이트를 더는 권장하지 않고, 생태계와 그 위에 구축된 모든 것을 새 트레이트로 옮긴다.이 아이디어들의 변형, 즉 두 가지를 결합하는 방식도 있다. 예를 들어, 기본적으로 제너레이터는 자기 참조를 허용하지 않되, 추가 문법으로 이를 허용할 수 있게 하되 사용자가 핀을 요구하도록 할 수 있다. 혹은 그 반대. 이렇게 둘을 결합할 수 있으므로, 처음에는 하나를 고르고, 나중에 필요성이 정말 크다고 판단되면 다른 쪽을 확장으로 도입할 수도 있다.
내 생각에는 세 번째 선택지는 현실적이지 않다. Rust 프로젝트는 앞의 두 선택지 중 하나를 고르고 그것을 안정화한 뒤, 정말 유연성이 크게 필요해지면 나중에 다른 하나에 대한 문법을 도입하는 편이 좋다. 그렇다면 기본(default) 선택으로 무엇이 최선인가가 남는다.
내 생각에는 첫 번째 선택지가 아마 가장 좋다. 변경이 가장 덜 침습적이고, 가장 직관적이며, 그 의미도 결국 제너레이터가 처음부터 이터레이터에 없던 표현력을 새로 얻지 않는다는 정도이기 때문이다. 이를 뒷받침하는 주된 근거는, Iterator는 그 역사 내내 자기 참조를 지원하지 못했지만, 이것이 문제로 드러나는 경우가 드물었다는 점이다. 반면 2017년에 사람들이 Future 조합자들을 쓰기 시작했을 때는, 자기 참조가 없다는 문제가 즉시 두드러졌다.
이터레이터는 대체로 수명이 짧기 때문에, 참조가 필요한 상태를 이터레이터 바깥에 두어도 대개 괜찮다. 제너레이터로 더 복잡한 이터레이터를 지금보다 쉽게 구현할 수 있게 되면, 사용 방식에 따라 문제가 드러날 가능성도 있지만, 실제로 기능을 써 보기 전에는 단정하기 어렵다. 이런 일반적 사용 방식의 차이는 이터레이터와 async 이터레이터의 큰 차이이기도 하다. AsyncIterator는 본질적으로 항상 어떤 IO나 조율을 수행하므로 더 오래 살고 더 복잡한 상태를 소유해야 하는 경향이 있다(여기서도 추상화보다는 패턴의 가치가 드러난다).
시간을 되돌릴 수 있다면, 나는 Iterator가 자기 자신에 대한 고정 참조를 받도록 만들었을 것이다. 이렇게 바꾸고 싶은 트레이트가 이것만 있는 것도 아니다 — Drop이 핀되지 않는 문제는 내 생각엔 더 나쁘다. 하지만 핀이라는 개념은 이 트레이트들이 안정화될 당시에는 존재하지 않았고, 에디션 경계를 넘어도 이제는 바꿀 수 없다. 안타깝다.
이것으로 Rust에서 제어 흐름 효과를 결합하는 문제, 특히 반복, 실패 가능성, 비동기성이 잘 합성되게 만드는 문제에 대한 연재를 마무리한다. 이는 2019년 MVP가 출시된 이래 async Rust가 직면한 가장 큰 문제 중 하나였는데, MVP에서는 비동기와 반복의 통합에 대한 구체 사항이 미해결로 남았기 때문이다. 내 의견은, 이 연재에서 설명했듯이 가장 단순하고, 최선이며, 가장 빠른 접근 — 그리고 현재의 Rust 개발 단계와 양립 가능한 유일한 접근 — 은 2024 에디션에 제너레이터를 포함시켜, 언어에 큰 변화를 주지 않고도 더 합성 가능한 방식으로 반복을 해결하는 것이라고 본다.
다시 Rust에 대해 블로깅한 경험은 좋았던 점과 아쉬운 점이 섞여 있었다. 사용자들로부터 매우 긍정적이고 힘이 되는 피드백을 많이 받았다 — 솔직히 말해, 때로는 이 글들이 받는 과분한 칭찬을 어떻게 받아들이면 좋을지 잘 모르겠지만, 어떤 방식으로든 반응해 준 모든 분들(비판도 포함하여)께 감사드린다. 반면 현재 Rust에 기여하고 있는 분들의 반응은 다소 미적지근하거나, 심지어 방어적으로 느껴졌다. 지금 내 위치에서 할 수 있는 일은, Rust가 무엇을 해야 한다고 생각하는지, 왜 그렇게 생각하는지 밝히는 것뿐이고, 내 의견이 성의 있게 고려되기를 바랄 뿐이다.
얼마나 빨리 될지는 모르겠지만, 앞으로 async Rust의 또 다른 연재를 이어가고자 한다. 다음 연재에서는 async Rust의 기존 동시성 원시(primitives)들의 문제를 탐구하고, 특히 "구조적 동시성(structured concurrency)"이 더 나은 접근을 제공할 수 있다는 생각에 주목할 것이다.