블랭킷 구현은 특정 구조체 하나에 대해 트레잇을 구현하는 것이 아니라, 어떤 조건을 만족하는 모든 타입에 대해 포괄적으로 트레잇을 구현하는 기법을 말한다.
이름처럼, 조건을 만족하는 모든 타입들을 하나의 blanket으로 덮어버리는 것과 같다고 해서 붙여진 이름이라고 한다.
pub trait MyFutureExt: Future {
fn map<F, U>(self, f: F) -> Map<Self, F>
where
Self: Sized,
F: FnOnce(Self::Output) -> U,
{
Map::new(self, f)
}
}
impl<T: ?Sized + Future> MyFutureExt for T {}
MyFutureExt
trait을 T가 Future와 ?Sized
조건을 만족한 모든 T
에 대해서 구현을 한다라는 의미이다.
이 기법 덕분에 우리가 만든 MyFutureExt
트레잇의 .map()
메서드가 Rust 생태계의 모든 Future
에 자동으로 적용이된다.
사용자는 그저 use my_library::MyFutureExt;
한 줄만 추가하면, 자신이 가진 모든 Future
에 .map()
메서드가 생긴 것 처럼 편리하게 사용할 수 있다.
pub trait MyFutureExt: Future {
fn map<F, U>(self, f: F) -> Map<Self, F>
where
Self: Sized,
F: FnOnce(Self::Output) -> U,
{
Map::new(self, f)
}
}
MyFutureExt
를 살펴보면 F
, U
를 제네릭타입으로 가지는 map
메서드를 정의하고 있다.
매개변수로 self
를 받고, F
타입을 받는다. 여기서 F
의 조건은 FnOnce
로 한 번만 호출되는 클로저이다.
클로저는 매개변수로 Self::Output
을 받는다. 여기서 어떻게 Self
가 Output
이라는 연관 타입을 가지는지 궁금해할 수 있다.
pub trait MyFutureExt: Future
이 부분에서 알 수 있다. MyFutureExt
를 구현하려는 모든 타입(Self)는 반드시 Future
트레잇도 구현해야 한다는 컴파일러에게 전하는 규칙이다. 따라서 Self
는 Output
이라는 연관 타입을 가지고 있을 수 밖에 없는 것이다.
Future
trait의 시그니처를 잠깐 살펴보면, 연관 타입인 Output
은 poll
메서드가 호출 뒤 반환되는 타입임을 알 수 있다.
pub trait Future {
/// The type of value produced on completion.
#[stable(feature = "futures_api", since = "1.36.0")]
#[lang = "future_output"]
type Output;
#[lang = "poll"]
#[stable(feature = "futures_api", since = "1.36.0")]
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
F
클로저는 매개변수로 Future
가 완료 시 반환하는 타입을 받아서 타입 U
를 반환한다.
즉, 제네릭 타입 U
는 .map
메서드에 전달된 클로저의 실제 반환 타입에 따라 컴파일러가 자동으로 추론한다.
예를 들어 .map(|_| "hello")
를 호출했다면 U
는 &'static str
이 되는 것이다.
.map
메서드를 호출하게 되면 제네릭타입의 Map
구조체를 반환하게 된다. Future
와 그리고 나중에 사용될 클로저를 담는 필드 값이 필요하다.
#[pin_project]
pub struct Map<Fut, F> {
#[pin]
future: Fut,
f: Option<F>, // 클로저는 한 번만 사용되므로 Option으로 감싸서 꺼내 쓴다.
}
제네릭 타입을 가지는 구조체 Map
은 아무런 조건이 없다. 이것은 매우 중요한 포인트이며, Rust의 일반적인 디자인 패턴이다.
이 시점에서는 단순히 future라는 필드는 어떤 타입인지는 모르지만 Fut
이라는 타입을 담아주고, f필드는 Option<F>
를 담아주겠다고 생각하면 된다.
impl<Fut, F> Map<Fut, F> {
pub fn new(future: Fut, f: F) -> Self {
Map { future, f: Some(f)}
}
}
이 구현조차도 new
라는 메서드를 만들어서 Fut
, F
제네릭 타입을 매개변수로 받아서 필드값에 넣어준다는 의미라고 생각하면 된다.
이 구조체를 만든 이유는 기존의 Future
에 .map
메서드를 사용하여 반환된 값을 클로저의 매개변수로 넘겨 실행하여 원하는 타입 형태로 받기 위함이다. 따라서 Future
trait을 구현해야한다.
impl<Fut, F, U> Future for Map<Fut, F>
where
Fut: Future,
F: FnOnce(Fut::Output) -> U,
{
type Output = U;
fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<Self::Output> {
let mut this = self.project();
let inner_poll_result = this.future.as_mut().poll(cx);
match inner_poll_result {
std::task::Poll::Ready(value) => {
let f = this.f.take().expect("Future Pinned after completion");
let result = f(value);
std::task::Poll::Ready(result)
},
std::task::Poll::Pending => std::task::Poll::Pending,
}
}
}
where
절을 통해서 조건을 부여해준다. Fut
타입은 Future
trait을 구현해야하고, F
타입은 클로저타입이고 U
타입을 반환한다고 조건을 명시하였다. 동작을 하게 만들고 싶을 때, 비로소 제약 조건이 필요해진다.
poll
메서드를 분석해 보면, self.protect()를 통해서 타입의 뷰
를 얻어온다. pin_proejct
크레이트에 대해서는 다음에 더 자세히 다루도록 하겠다. future
필드를 통해서 원본 Future의 poll
메서드를 호출해 준다. match
를 사용해 만약에 Pending
이라면 똑같이 Pending
을 반환하도록 하고, 완료가 되었다면, 저장해놓은 클로저를 실행하여 결과를 얻는다. 해당 결과를 담아서 반환해 주면 된다.
Future를 Pin으로 감싸야 하는 이유?
<추후 공개>
Future
의 확장 트레이트를 사용하기 위한 준비는 끝났다. MyFutureExt
를 블랜킷 구현을 했기 때문에 모든 Future
객체에 .map
메서드가 사용가능할 것이다.
#[tokio::main]
async fn main() {
let base_future = async { 10 };
println!("Mapping the future...");
let mapped_future = base_future.map(|value| {
println!("Original future completed with: {}", value);
value * 2
});
let result = mapped_future.await;
println!("Final result after map: {}", result);
}
실제 사용 코드이다. base_future
라는 Future
객체를 만들어준다. .map
메서드를 호출하여 매개변수로 클로저를 넘겨준다.
결괏값은 10에 2가 곱해진 20이 나올 것이다.
출력값은 아래와 같다.
// output
Mapping the future...
Original future completed with: 10
Final result after map: 20
Future
를 다루는데 위와 같은 패턴이 엄청 나오는 것 같다. 라이브러리를 이해할 수 없어서 공부를 하게 되었다.
아직도 완벽하게 이해하지는 못했지만, 익숙해지는 것 밖에 답이 없는 것 같다.