Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

트레이트 내 반환 위치 impl 트레이트

트레이트 내 반환 위치 impl 트레이트(RPITIT)는 개념적으로(그리고 #112988 이후로는 실제로도) 트레이트 메서드의 RPIT를 제네릭 연관 타입(GAT)으로 바꾸어 주는 문법적 설탕이며, 사용자가 해당 GAT를 트레이트 쪽이나 impl 쪽에서 직접 정의할 필요가 없습니다.

RPITIT는 원래 트레이트 내 async fn(AFIT) 지원을 추가한 #101224에서 구현되었는데, 이전에 RFC를 거친 AFIT를 구현하는 과정에서 RPITIT의 구현도 자연스럽게 함께 얻어졌기 때문입니다. 이후 RFC 3425에서 별도로 RFC가 작성되었고, 이는 T-lang의 승인을 받았습니다.

어떻게 동작합니까?

이 문서는 대체로 컴파일 파이프라인 순서를 따릅니다:

  1. AST 로워링(AST -> HIR)
  2. HIR ty 로워링(HIR -> rustc_middle::ty 데이터 타입)
  3. typeck

AST 로워링

RPITIT의 AST 로워링은 RPIT의 로워링과 거의 같습니다. 여전히 이들을 hir::ItemKind::OpaqueTy로 로워링합니다. 차이점은 다음 두 가지입니다:

불투명 타입에 대해 in_trait를 기록합니다. 이는 HIR ty 로워링, HIR을 다루는 진단 등에 해당 불투명 타입이 RPITIT임을 알려 줍니다.

아래에서 설명할 불투명 타입의 lifetime_mapping들을 기록합니다.

여담: 불투명 타입의 라이프타임 복제

(RPITIT뿐만 아니라) 모든 불투명 타입은 캡처한 라이프타임을 해당 불투명 타입에 국한된 새로운 라이프타임 매개변수로 복제하게 됩니다. 이렇게 하는 주된 이유는 RPIT가 캡처한 늦은 바인딩(late-bound) 인자를 “구체화(reify)”1할 수 있어야, 즉 이른 바인딩(early-bound) 인자로 만들 수 있어야 하기 때문입니다. 그래야 이들을 불투명 타입의 제네릭 인자로 사용할 수 있고, 나중에 숨겨진 타입을 인스턴스화하는 데에도 사용할 수 있습니다. AST 로워링 중에는 어떤 라이프타임이 이른 바인딩인지 늦은 바인딩인지 알 수 없으므로, 모든 라이프타임에 대해 이 작업을 수행합니다.

RPITIT에서 주로 추가된 점은, 로워링 중에 캡처된 라이프타임과 이에 대응하는 복제된 라이프타임 사이의 관계를 추가 필드인 OpaqueTy::lifetime_mapping에 기록해 추적한다는 것입니다. 이 라이프타임 매핑은 나중에 clauses_of에서 복제된 라이프타임과 원본 라이프타임이 같도록 강제하는 바운드를 설치하는 데 사용되며, 이는 이러한 GAT를 올바르게 타입 검사하기 위한 것입니다. 이에 대해서는 아래에서 다룹니다.

참고

중복 없이 로워링할 수 있다면 더 좋을 수 있으며, 이를 위해서는 조기 바인딩 라이프타임과 후기 바인딩 라이프타임을 구분하지 않아야 한다고 생각합니다. 따라서 제네릭에서 후기 바인딩 라이프타임 고려 #103448과 같은 해결책이 필요하며, 이어서 impl 트레이트에 대해 함수 라이프타임 상속 #103449와 유사한 PR도 필요합니다.

HIR ty 로워링

HIR ty 로워링의 주된 변경 사항은 RPITIT에 대한 hir::TyKind::OpaqueDef를 불투명 타입 대신 프로젝션으로 로워링한다는 것이며, 이때 트레이트 안의 새 연관 타입을 위해 새로 합성한 def-id를 사용합니다. 이 def-id를 정확히 어떻게 얻는지는 다음 절에서 설명합니다.

즉, RPITIT에 대해 lower_ty를 호출할 때마다 불투명 타입 대신 프로젝션을 돌려받게 됩니다. 이 프로젝션은 이후 올바른 값으로 정규화될 수 있습니다. 트레이트 안에 있다면 원래의 불투명 타입으로, impl 안에 있다면 RPITIT의 추론된 타입으로 정규화됩니다.

합성 연관 타입으로 로워링하기

쿼리 피딩을 사용하여, 메서드에 나타나는 RPITIT에 대해 트레이트 쪽과 impl 쪽 모두에 새로운 연관 타입을 합성합니다.

트레이트에서 RPITIT 로워링하기

트레이트의 모든 연관 타입을 수집하기 위해 트레이트에 대해 tcx.associated_item_def_ids(trait_def_id)가 호출되면, 이전에는 이 쿼리가 단순히 트레이트의 자식인 HIR 항목들의 def-id를 반환했습니다. #112988 이후에는 추가로 트레이트의 각 메서드에 대해 tcx.associated_types_for_impl_traits_in_associated_fn(trait_method_def_id)가 반환하는 def-id들을 더합니다. 이 함수는 각 트레이트 메서드를 순회하면서 시그니처에 나타나는 모든 RPITIT를 수집한 다음, 각 RPITIT에 대해 associated_type_for_impl_trait_in_trait를 호출하며, 이 함수가 새로운 연관 타입을 합성합니다.

impl에서 RPITIT 로워링하기

마찬가지로 impl의 HIR 항목들과 함께, 각 impl 메서드에 대해 해당 impl 메서드의 associated_types_for_impl_traits_in_associated_fn을 모두 추가로 더합니다. 이는 associated_type_for_impl_trait_in_impl을 호출하며, 이 함수는 대응하는 트레이트 메서드에서 오는 각 RPITIT에 대해 연관 타입 정의를 합성합니다.

새로운 연관 타입 합성하기

각 RPITIT에 대한 합성 GAT의 새로운 def-id를 합성하기 위해 쿼리 피딩(TyCtxtAt::create_def)을 사용합니다.

로컬에서는 rustc의 쿼리 대부분이 값을 계산하기 위해 항목의 HIR에 대해 패턴 매칭을 합니다. RPITIT에는 실제로 연관된 HIR이 없거나, 적어도 연관 타입에 대응하는 HIR은 없으므로, opt_def_kind, associated_item, visibility, defaultness 같은 많은 쿼리를 즉시 계산하여 피딩해야 합니다.

이러한 쿼리 대부분의 값은 자명합니다. RPITIT가 개념적으로 정보 대부분을 부모 함수로부터 상속하거나(예: visibility), 연관 타입이기 때문에 당연히 알 수 있기 때문입니다(opt_def_kind).

그 밖의 일부 쿼리는 더 복잡하거나 피딩할 수 없으며, 그중 흥미로운 것들을 아래에 설명합니다.

트레이트에 대한 generics_of

RPITIT에 대한 GAT는 개념적으로 그것이 유래한 RPIT와 동일한 제네릭을 상속합니다. 그러나 메서드를 제네릭의 부모로 두는 대신, 트레이트가 부모가 됩니다.

현재는 RPIT의 제네릭과 메서드 제네릭을 가져와 둘 다 하나의 새로운 제네릭 목록으로 평탄화하고, 각 매개변수의 def-id는 그대로 유지하는 방식으로 처리하고 있습니다. (이로 인해 def-id가 잘못된 부모를 가지는 문제가 생길 수 있지만, 최악의 경우라도 진단 문제를 일으키는 정도입니다. 이것이 결국 문제가 된다면, 부모가 GAT인 제네릭 매개변수에 대해 새로운 def-id를 합성할 수 있습니다.)

예시로 살펴보기
#![allow(unused)]
fn main() {
trait Foo {
    fn method<'early: 'early, 'late, T>() -> impl Sized + Captures<'early, 'late>;
}
}

이는 다음과 같이 디슈가링됩니다…

#![allow(unused)]
fn main() {
trait Foo {
    //       vvvvvvvvv method's generics
    //                  vvvvvvvvvvvvvvvvvvvvvvvv opaque's generics
    type Gat<'early, T, 'early_duplicated, 'late>: Sized + Captures<'early_duplicated, 'late>;

    fn method<'early: 'early, 'late, T>() -> Self::Gat<'early, T, 'early, 'late>;
}
}
impl에 대한 generics_of

impl의 GAT에 대한 제네릭은 조금 더 흥미롭습니다. 이 제네릭은 (트레이트 정의에서 온) RPITIT 자체의 제네릭을 impl 메서드의 제네릭 뒤에 덧붙여 구성됩니다. 이 방식에도 위와 같은 문제가 있어 GAT의 제네릭이 잘못된 부모를 가진 def-id의 매개변수를 갖게 되지만, 이는 진단에서만 문제를 일으킬 것입니다.

새로운 제네릭 def-id를 합성한다면 이 문제도 비슷하게 고칠 수 있지만, 이는 나중에 상위 호환 방식으로 할 수 있으며, 관심 있는 새 기여자가 맡을 수도 있을 것입니다.

opt_rpitit_info

explicit_clauses_of처럼, 일부 쿼리는 즉시 피딩하려고 하면 순환을 일으킬 정보를 계산하는 데 의존합니다. 따라서 RPITIT의 GAT에 대한 올바른 값을 반환하는 일은 clauses_of 프로바이더에 맡깁니다. 이를 위해 쿼리 초반에 opt_rpitit_info를 사용해 연관 타입이 합성된 것인지 감지합니다. 이 쿼리는 연관 타입이 합성된 것이면 Some을 반환합니다.

그러면 explicit_clauses_of 같은 쿼리를 수행하는 동안 다음과 같이 연관 타입이 합성된 것인지 감지할 수 있습니다.

#![allow(unused)]
fn main() {
fn explicit_clauses_of(tcx: TyCtxt<'_>, def_id: LocalDefId) -> ... {
    if let Some(rpitit_info) = tcx.opt_rpitit_info(def_id) {
        // Do something special for RPITITs...
        return ...;
    }

    // The regular computation which relies on access to the HIR of `def_id`.
}
}
explicit_clauses_of

RPITIT는 트레이트 쪽과 impl 쪽 모두에서, 자신을 정의한 메서드의 술어(predicate)를 복사하는 것으로 시작합니다.

또한 “양방향 outlives” 술어를 설치합니다. 구체적으로, 캡처된 각 조기 바인딩 라이프타임마다 양방향으로 region-outlives 절을 추가하여, 해당 라이프타임이 로워링 결과로 생긴 중복된 조기 바인딩 라이프타임과 같도록 제약합니다. 이는 예제로 보는 것이 가장 이해하기 쉽습니다.

#![allow(unused)]
fn main() {
trait Foo<'a> {
    fn bar() -> impl Sized + 'a;
}

// Desugars into...

trait Foo<'a> {
    type Gat<'a_duplicated>: Sized + 'a
    where
        'a: 'a_duplicated,
        'a_duplicated: 'a;
    //~^ Specifically, we should be able to assume that the
    // duplicated `'a_duplicated` lifetime always stays in
    // sync with the `'a` lifetime.

    fn bar() -> Self::Gat<'a>;
}
}
assumed_wf_types

트레이트와 impl 양쪽의 GAT는 RPITIT를 정의하는 트레이트 메서드의 assumed_wf_types를 상속합니다. 이는 다음 코드가 lowering되었을 때 well-formed임을 보장하기 위한 것입니다.

#![allow(unused)]
fn main() {
trait Foo {
    fn iter<'a, T>(x: &'a [T]) -> impl Iterator<Item = &'a T>;
}

// which is lowered to...

trait FooDesugared {
    type Iter<'a, T>: Iterator<Item = &'a T>;
    //~^ assumed wf: `&'a [T]`
    // Without assumed wf types, the GAT would not be well-formed on its own.

    fn iter<'a, T>(x: &'a [T]) -> Self::Iter<'a, T>;
}
}

assumed_wf_types는 로컬 def id에 대해서만 정의되므로, RPIT를 가진 외부 트레이트의 impl에 대해 assumed_wf_types를 올바르게 구현하려면 RPITIT의 assumed wf 타입을 extern 쿼리 assumed_wf_types_for_rpitit에 인코딩해야 합니다.

타입 검사

RPITIT 추론 알고리즘

RPITIT 추론 알고리즘은 collect_return_position_impl_trait_in_trait_tys에 구현되어 있습니다.

개요: impl 메서드와 트레이트 메서드가 주어지면, 트레이트 메서드를 가져와 시그니처에 있는 각 RPITIT를 추론 변수로 인스턴스화합니다. 그런 다음 이 트레이트 메서드 시그니처를 impl 메서드 시그니처와 동일시(equate)하고, 그 결과로 생기는 모든 의무(obligation)를 처리하여 메서드 내 모든 RPITIT의 타입을 추론합니다.

이 메서드는 또한 각 RPITIT의 숨겨진 타입이 실제로 impl Trait의 바운드를 만족하는지, 즉 impl Trait = Foo로 추론했다면 Foo: Trait가 성립하는지 확인하는 역할도 맡습니다.

예시는 다음과 같습니다.
#![allow(unused)]
#![feature(return_position_impl_trait_in_trait)]

fn main() {
use std::ops::Deref;

trait Foo {
    fn bar() -> impl Deref<Target = impl Sized>;
             // ^- RPITIT ?0        ^- RPITIT ?1
}

impl Foo for () {
    fn bar() -> Box<String> { Box::new(String::new()) }
}
}

결과적으로 fn() -> ?0 형태의 트레이트 시그니처와, 중첩된 의무 ?0: Deref<Target = ?1>, ?1: Sized를 얻게 됩니다. impl 시그니처는 fn() -> Box<String>입니다.

이 시그니처들을 동일시하면 ?0 = Box<String>를 얻고, 이어서 의무 Box<String>: Deref<Target = ?1>를 처리하면 ?1 = String을 얻으며, 나머지 의무 String: Sized는 참으로 평가됩니다.

알고리즘이 끝나면, 연관 타입 def-id에서 시그니처로부터 추론된 구체 타입으로의 매핑을 얻게 됩니다. 이 매핑은 각 RPITIT에 대해 type Assoc = ...의 = 뒤에 와야 할 타입을 기술하므로, 이 매핑을 사용하여 impl 안의 합성 연관 타입에 대한 type_of를 구현할 수 있습니다.

RPITIT 숨겨진 타입 추론에서의 암시적 바운드

collect_return_position_impl_trait_in_trait_tys는 이행과 영역 해석을 수행하므로, compare_method_clause_entailment와 동일하게 기대되는 암시적 바운드로 영역 의무를 증명할 수 있도록 assumed_wf_types를 제공해야 합니다.

메서드의 반환 타입은 가정된 WF 타입 중 하나로 간주되고, 불투명 타입 추론을 위해 반환 타입을 추론 변수로 즉시 폴딩하므로, 불투명 타입 추론이 끝나면 반환 타입은 RPITIT의 숨겨진 타입을 포함하도록 해석됩니다. 이는 RPITIT의 숨겨진 타입이 적격하다는 것을 독립적으로 증명하지 않은 채 적격하다고 가정된다는 뜻입니다. 이로 인해 미묘한 건전성 버그가 발생했습니다. 이러한 순환 추론을 막기 위해, 메서드 반환 타입에 있는 RPITIT의 숨겨진 타입을 대신 플레이스홀더로 바꾸며, 플레이스홀더로부터는 암시적 적격성 바운드가 생기지 않습니다.

기본 트레이트 본문

다음과 같은 기본 트레이트 본문을 타입 검사하려면 흥미로운 편법이 하나 필요합니다.

#![allow(unused)]
fn main() {
trait Foo {
    fn bar() -> impl Sized {
        1i32
    }
}
}

Foo::bar의 param-env에 프로젝션 술어를 설치하여 RPITIT의 GAT가 RPITIT의 불투명 타입으로 정규화된다고 가정할 수 있게 해야 합니다. 이는 트레이트 메서드와 RPITIT의 GAT가 항상 “동기화“되어 있다는 관찰에 기반합니다. 즉, 둘 중 하나는 다른 하나도 재정의될 때에만 재정의됩니다.

이를 위 코드를 비슷하게 디슈가링한 다음 코드와 비교해 보십시오. 이 코드는 같은 가정에 의존할 수 없으므로 실패합니다.

#![allow(unused)]
#![feature(impl_trait_in_assoc_type)]
#![feature(associated_type_defaults)]

fn main() {
trait Foo {
    type RPITIT = impl Sized;

    fn bar() -> Self::RPITIT {
        01i32
    }
}
}

다운스트림 impl이 bar의 구현을 제공하지 않고도 이론적으로 RPITIT의 구현을 제공할 수 있기 때문에 실패합니다:

error[E0308]: mismatched types
--> src/lib.rs:8:9
 |
5 |     type RPITIT = impl Sized;
 |     ------------------------- associated type defaults can't be assumed inside the trait defining them
6 |
7 |     fn bar() -> Self::RPITIT {
 |                 ------------ expected `<Self as Foo>::RPITIT` because of return type
8 |         01i32
 |         ^^^^^ expected associated type, found `i32`
 |
 = note: expected associated type `<Self as Foo>::RPITIT`
                       found type `i32`

적격성 검사

RPITIT의 적격성은 일반 연관 타입과 똑같이 검사합니다.

clauses_of에 중복된 조기 바인딩 라이프타임을 원래 라이프타임과 연결하는 라이프타임 바운드를 추가했고, RPITIT가 비롯된 메서드의 WF 타입을 상속하는 assumed_wf_types를 구현했으므로(#113704), GAT를 일반 GAT처럼 WF 검사해도 문제가 없습니다.

망가진 것, 이상한 것 등

특수화는 심각하게 망가져 있습니다

위에서 설명한 “기본 트레이트 메서드“는 특수화와 잘 상호 작용하지 않습니다. 해당 프로젝션 바운드를 트레이트 기본 메서드에만 설치하고 impl 메서드에는 설치하지 않기 때문입니다. 특수화는 이미 상당히 망가져 있으므로 자세히 설명하지는 않겠지만, 이는 현재 다음에서 추적 중인 버그입니다: * tests/ui/impl-trait/in-trait/specialization-broken.rs

프로젝션에는 변성이 없습니다

이 코드는 프로젝션에 변성이 없기 때문에 실패합니다:

#![allow(unused)]
#![feature(return_position_impl_trait_in_trait)]

fn main() {
trait Foo {
    // Note that the RPITIT below does *not* capture `'lt`.
    fn bar<'lt: 'lt>() -> impl Eq;
}

fn test<'a, 'b, T: Foo>() -> bool {
    <T as Foo>::bar::<'a>() == <T as Foo>::bar::<'b>()
    //~^ ERROR
    // (requires that `'a == 'b`)
}
}

이는 <T as Foo>::Rpitit<'a>와 <T as Foo>::Rpitit<'b>가 라이프타임을 캡처하지 않더라도 둘을 서로 관련지을 수 없기 때문입니다. 일반 불투명 타입을 사용했다면 해당 라이프타임 매개변수에 대해 쌍변(bivariant)이므로 이 코드는 동작했을 것입니다:

#![allow(unused)]
#![feature(return_position_impl_trait_in_trait)]

fn main() {
fn bar<'lt: 'lt>() -> impl Eq {
    ()
}

fn test<'a, 'b>() -> bool {
    bar::<'a>() == bar::<'b>()
}
}

하지만 RPITIT의 캡처 동작은 어차피 스코프 내의 모든 라이프타임을 캡처하도록 변경될 가능성이 높으므로, 이는 아마 괜찮을 것입니다. 프로젝션을 관련지을 때 RPITIT의 변성을 고려하도록 한다면, 나중에 상위 호환되는 방식으로 이 제약을 완화할 수도 있습니다.


  1. 이는 compiler-errors(rustc 컴파일러 기여자)식 용어이며, 정확하다고 주장하는 것은 아닙니다 :^) ↩