`Future::poll`이 `self: Pin<&mut Self>`를 사용하는 이유와, Pin이 기존 인터페이스 및 주소 민감형 타입에 미치는 영향을 살펴봅니다.
한동안 Future::poll 메서드가 시그니처에서 왜 self: Pin<&mut Self>를 받는지 궁금했습니다. 분명 타당한 이유가 있으리라고 생각했지만, 동료 WG Async 구성원들에게 물었을 때 정확히 왜 그런지 즉시 아는 사람은 없는 듯했습니다. 아니면 알고 있었는데, 제가 따라가는 데 조금 어려움을 겪었을 수도 있습니다. 어느 쪽이든, 이제는 이유를 알아낸 것 같고, 다른 사람들도 따라올 수 있도록 후세를 위해 이를 분명히 설명하고 싶습니다.
예를 들어 MyType 타입과 MyTrait 트레이트를 생각해 봅시다. MyType이 고정되었을 때에만 사용할 수 있는 MyTrait 구현을 작성할 수 있습니다.
trait MyTrait {
fn my_method(&mut self) {}
}
struct MyType;
impl<T> MyTrait for Pin<&mut MyType> {}
함수 내부에서는 이에 대한 바운드도 작성할 수 있습니다. 트레이트 바운드의 왼쪽에는 제네릭이나 함수 시그니처에 포함된 타입뿐 아니라 임의의 타입도 들어갈 수 있다는 사실을 알려 준 Eric Holk에게 감사를 전합니다. 저는 전혀 몰랐습니다.
이렇게 하면 어떤 타입 T를 값으로 받고, 그 값을 고정하면 MyTrait를 구현하게 된다는 것을 표현할 수 있습니다.
fn my_function<T>(input: T)
where
for<'a> Pin<&'a mut T>: MyTrait,
{
let pinned_input = pin!(input);
}
MyTrait::my_method 내부에서 self의 타입은 &mut Pin<&mut Self>가 됩니다. 이는 소유 타입 Pin<&mut Self>와 같지는 않지만, 다행히 Pin::as_mut를 호출해 소유 타입으로 변환할 수 있습니다. 문서에는 가변 참조에서 소유 인스턴스로 가는 것이 여기서는 왜 안전한지에 관한 자세한 설명이 있습니다. 이는 직관적으로 Rust의 소유권 규칙에 어긋나 보입니다.
그런데 where 절을 가진 제네릭 타입 T를 작성하는 대신, 연관 위치의 impl 트레이트를 사용하고 싶다면 어떻게 될까요? 다음과 같은 코드를 작성하고 싶을 수 있습니다.
// how do we express those same bounds here?
fn my_function<T>(input: impl ???) {
let pinned_input = pin!(input);
}
하지만 정확히 그 바운드를 표현할 방법은 없습니다. 일반 제네릭과 달리 연관 위치의 impl 트레이트는 바운드의 왼쪽을 표현할 수 없고, 오른쪽만 이름 붙일 수 있습니다. 반환 위치의 impl 트레이트를 사용하려 할 때는 이것이 더욱 두드러집니다.
어떤 타입 T를 반환하는 함수를 예로 들어 봅시다. 구체적인 트레이트 바운드를 사용하면, 고정되었을 때 MyTrait를 구현하는 타입을 반환한다는 것을 표현할 수 있습니다.
fn my_function<T>() -> T
where
for<'a> Pin<&'a mut T>: MyTrait,
{
MyType {}
}
하지만 반환 위치의 impl 트레이트를 사용해 같은 함수를 표현하려 하면 그 바운드를 표현할 능력을 잃습니다. 고정되었을 때 기능을 노출하는 -> impl Trait를 표현할 유일한 해결책은 Pin을 메서드 시그니처의 직접적인 일부로 만들고, Pin<&mut Type>에 대해 트레이트를 구현하지 않는 것입니다.
trait MyTrait {
fn my_method(self: Pin<&mut Self>) {} // ← note the signature of self here
}
struct MyType;
impl MyTrait for MyType {} // ← no longer implemented for `Pin<&mut MyType>`
그러면 갑자기 MyType이 고정된 경우에만 메서드를 호출할 수 있는 -> impl MyTrait를 표현할 수 있습니다. Unpin은 그렇지 않은 타입을 위한 선택 해제 수단입니다.
fn my_function() -> impl MyTrait { // Can be either pinned or not!
MyType {}
}
구체적으로 말하면, 고정된 값과 함께 동작하면서 다른 모든 언어 기능도 평소처럼 사용하고 싶은 트레이트를 만들려면 메서드 시그니처의 일부로 self: Pin<&mut Self>를 사용해야 합니다. 새 트레이트에는 큰 문제가 아닐 수도 있지만, 표준 라이브러리의 모든 기존 트레이트에는 영향을 미칩니다.
예를 들어 Iterator 트레이트를 생각해 봅시다. 단순히 impl Iterator for Pin<&mut T>를 작성하고 반환 위치의 impl 트레이트가 동작하리라 기대할 수는 없습니다. 대신 여기서 예상되는 경로는 self: Pin<&mut T>를 받는 새 트레이트 PinnedIterator를 도입하는 것으로 보입니다. 이는 이미 self: Pin<&mut Self>를 받는 Future를 제외한 표준 라이브러리의 모든 기존 트레이트가 공유하는 하위 호환성 문제입니다. 이는 상당히 큰 제약이며, Future를 넘어선 Pin의 실현 가능성을 논의할 때 고려할 가치가 있습니다. Iterator의 경우에는 적어도 다음 변형들을 새로 만들어야 한다는 뜻입니다.
// iterator
trait Iterator {
type Item;
fn next(&mut self) -> Option<Item>;
}
// address-sensitive iterator
trait PinnedIterator {
type Item;
fn next(self: Pin<&mut self>) -> Option<Item>;
}
이로부터 몇 가지 영향을 더 살펴보면, Rust 사용자가 Rust에서 주소 민감형 타입을 선언할 수 있게 하려 할 때 현재 가장 가능성 높은 경로는 std::io 하위 모듈의 트레이트를 다음과 비슷한 형태로 복제하는 것입니다.
mod io {
pub trait Read { ... }
pub trait PinnedRead { ... }
pub trait Write { ... }
pub trait PinnedWrite { ... }
pub trait Seek { ... }
pub trait PinnedSeek { ... }
pub trait BufRead { ... }
pub trait PinnedBufRead { ... }
}
async 및 try와 마찬가지로 고정은 트레이트의 조합적 속성이며, 이는 지수적인 양의 중복으로 이어집니다. 다행히 트레이트 복제만이 가능한 유일한 경로는 아닙니다. 기존 인터페이스에 대한 Pin 다형성의 어떤 형태도 가능해 보입니다. 단, 그것을 정식화하는 방식을 바꿀 의향이 있어야 합니다. 이것이 제가 예를 들어 Send 및 Sync처럼 조합 가능한 Move 자동 트레이트 설계를 정식화하게 된 계기입니다.
동료 WG Async 구성원들에게 짧게 감사를 전하고 싶습니다. 우리는 이것에 관해 많이 이야기했고, 함께 검토하는 일이 도움이 되었습니다. 실제로 이 글을 올리기까지 몇 달이 걸리기는 했지만 말입니다. 하지만 이 글에 있는 모든 실수는 분명 제 책임입니다.
이 글에서는 주로 Future가 왜 &mut self를 받고 impl Future for Pin<&mut T>에 의존하는 대신 self: Pin<&mut Self>를 받는지 설명하고자 했습니다. 그럴 만한 좋은 이유를 찾았다고 생각하며, 그것은 다시 바운드의 왼쪽과 오른쪽에 관한 문제입니다. 이는 일반화된 자기 참조 타입을 위한 모든 설계가 다음 사항을 포착할 수 있어야 한다는 제 가설도 확인해 줍니다.
Option 없이 자기 참조를 안전하게 초기화할 수 있는 능력이 글은 특히 네 번째 요구 사항, 즉 기존 인터페이스를 이동 불가능성으로 확장하는 능력을 다루었습니다. 이를 해결하는 방식은 인터페이스를 복제하는 것일 수 있고(예: Iterator와 PinnedIterator), 자동 트레이트를 통한 조합일 수도 있습니다(예: Move). 다른 방법도 가능할 수 있으며, 아이디어가 있는 분들이 공유해 주시기를 권합니다.
PoignardAzur는 별도로 설명한 글에서 고정된 타입을 왜 그리고 어떻게 제자리에서 생성할 수 있어야 하는지 설명했습니다. 그 글은 목록의 세 번째와 여섯 번째 요구 사항 모두의 예시를 보였습니다. -> pin Type 표기법을 통한 배치의 한 형태를 도입했습니다. 이는 제가 제 글에서 도입한, Mara의 super let 글에서 가져와 변형한 좀 더 일반적인 -> super Type 표기법과 비슷합니다.
이 글이 왜 Pin이 인터페이스의 일부여야 하는지를 적어도 부분적으로 명확히 하는 데 도움이 되기를 바랍니다. 또한 이것이 표준 라이브러리의 엄격한 하위 호환성 요구 사항과 상호작용하는 방식을 고려할 때의 논리적 결과도 분명히 했기를 바랍니다. 이동 불가능성이라는 축을 기준으로 전체 인터페이스를 복제하는 것보다 우리는 더 잘할 수 있고, 또 그래야 한다고 믿기 때문입니다.