ty 모듈: 타입 표현하기
ty 모듈은 Rust 컴파일러가 내부적으로 타입을 표현하는 방식을 정의합니다. 또한 컴파일러의 핵심 자료 구조인 타이핑 컨텍스트(tcx 또는 TyCtxt)도 정의합니다.
ty::Ty
rustc가 타입을 어떻게 표현하는지 이야기할 때는 보통 Ty라는 타입을 가리킵니다. 컴파일러에는 Ty와 관련된 모듈과 타입이 꽤 많습니다(Ty 문서).
여기서 말하는 Ty는 구체적으로 rustc_middle::ty::Ty입니다(rustc_hir::Ty가 아닙니다). 이 구분이 중요하므로, ty::Ty의 세부 사항을 살펴보기 전에 먼저 이를 설명하겠습니다.
rustc_hir::Ty 대 ty::Ty
rustc의 HIR은 고수준 중간 표현으로 생각할 수 있습니다. HIR은 사용자가 작성한 구문을 표현하며 파싱과 약간의 디슈가링을 거친 뒤에 얻어지므로, 대체로 AST와 비슷합니다(이 장을 참고하십시오). HIR에도 타입 표현이 있지만, 실제로는 사용자가 작성한 것, 즉 그 타입을 나타내기 위해 사용자가 쓴 것을 더 많이 반영합니다.
반면 ty::Ty는 타입의 의미론, 즉 사용자가 작성한 것의 의미를 표현합니다. 예를 들어 rustc_hir::Ty는 사용자가 프로그램에서 u32라는 이름을 두 번 사용했다는 사실을 기록하지만, ty::Ty는 두 사용이 모두 같은 타입을 가리킨다는 사실을 기록합니다.
예: fn foo(x: u32) → u32 { x }
이 함수에서 u32가 두 번 나타나는 것을 볼 수 있습니다. 우리는 이것이 같은 타입이라는 것, 즉 함수가 인자를 받아 같은 타입의 인자를 반환한다는 것을 알지만, HIR의 관점에서는 이들이 프로그램의 서로 다른 두 위치에 나타나므로 서로 다른 두 개의 타입 인스턴스가 존재하게 됩니다. 즉, 이들은 서로 다른 두 개의 Span(위치)을 가집니다.
예: fn foo(x: &u32) -> &u32
또한 HIR에는 일부 정보가 빠져 있을 수 있습니다. 이 &u32 타입은 불완전합니다. 완전한 Rust 타입에는 실제로 라이프타임이 있지만, 우리는 그 라이프타임을 작성할 필요가 없었기 때문입니다. 정보를 채워 넣는 생략 규칙도 몇 가지 있습니다. 그 결과는 fn foo<'a>(x: &'a u32) -> &'a u32와 같은 모습일 수 있습니다.
HIR 수준에서는 이러한 것들이 명시되지 않으므로, 그림이 다소 불완전하다고 말할 수 있습니다. 그러나 ty::Ty 수준에서는 이러한 세부 사항이 추가되어 완전해집니다. 게다가 u32와 같은 주어진 타입에 대해 정확히 하나의 ty::Ty만 존재하며, 그 ty::Ty는 rustc_hir::Ty와 달리 특정 사용처가 아니라 프로그램 전체의 모든 u32에 사용됩니다.
요약하면 다음과 같습니다.
rustc_hir::Ty | ty::Ty |
|---|---|
| 타입의 구문을 기술합니다. 즉, 사용자가 작성한 것(약간의 디슈가링 포함)입니다. | 타입의 의미론을 기술합니다. 즉, 사용자가 작성한 것의 의미입니다. |
각 rustc_hir::Ty는 프로그램의 해당 위치에 대응하는 고유한 스팬을 가집니다. | 사용자 프로그램의 어느 한 위치에 대응하지 않습니다. |
rustc_hir::Ty에는 제네릭과 라이프타임이 있지만, 그중 일부 라이프타임은 LifetimeKind::Implicit 같은 특수 마커입니다. | ty::Ty는 사용자가 제네릭과 라이프타임을 생략했더라도 이를 포함한 전체 타입을 가집니다 |
fn foo(x: u32) -> u32 { } - u32의 각 사용을 나타내는 rustc_hir::Ty가 두 개 있고, 각각 자신의 Span을 가지며, rustc_hir::Ty만으로는 둘이 같은 타입이라는 것을 알 수 없습니다 | fn foo(x: u32) -> u32 { } - 프로그램 전체에서 u32의 모든 인스턴스에 대해 하나의 ty::Ty가 있으며, ty::Ty를 통해 u32의 두 사용이 같은 타입을 의미한다는 것을 알 수 있습니다. |
fn foo(x: &u32) -> &u32 { } - 이번에도 rustc_hir::Ty가 두 개입니다. 참조의 라이프타임은 LifetimeKind::Implicit라는 특수 마커를 사용하여 rustc_hir::Ty에 나타납니다. | fn foo(x: &u32) -> &u32 { }- 하나의 ty::Ty입니다. 이 ty::Ty는 숨겨진 라이프타임 매개변수를 가집니다. |
순서
HIR은 AST로부터 직접 만들어지므로, 어떤 ty::Ty보다도 먼저 생성됩니다. HIR이 만들어진 후에는 기본적인 타입 추론과 타입 검사가 수행됩니다. 타입 추론 중에 우리는 모든 것의 ty::Ty가 무엇인지 알아내고, 어떤 것의 타입이 모호한지도 검사합니다. 그런 다음 ty::Ty는 모든 것이 예상된 타입을 가지는지 확인하는 타입 검사에 사용됩니다. hir_ty_lowering 모듈은 rustc_hir::Ty를 ty::Ty로 로워링하는 코드가 있는 곳입니다. 주로 사용되는 루틴은 lower_ty입니다. 이 작업은 타입 검사 단계에서 일어나지만, “이 함수는 어떤 인자 타입을 기대하는가?“와 같은 질문을 하려는 컴파일러의 다른 부분에서도 일어납니다.
의미론이 Ty의 두 인스턴스를 어떻게 이끄는가
HIR은 타입 정보에 대해 가장 적게 가정하는 관점이라고 생각할 수 있습니다. 우리는 두 대상이 같은 것으로 증명되기 전까지는 서로 다르다고 가정합니다. 다시 말해, 그것들에 대해 아는 것이 적으므로 그것들에 대해 가정하는 것도 적어야 합니다.
구문적으로 그것들은 두 개의 문자열, 즉 N번째 줄 20번째 열의 "u32"와 N번째 줄 35번째 열의 "u32"입니다. 우리는 아직 그것들이 같다는 것을 알지 못합니다. 그래서 HIR에서는 그것들을 서로 다른 것처럼 취급합니다. 나중에 우리는 그것들이 의미적으로 같은 타입이라는 것을 결정하며, 그것이 우리가 사용하는 ty::Ty입니다.
또 다른 예로 fn foo<T>(x: T) -> u32를 생각해 봅시다. 누군가 foo::<u32>(0)를 호출한다고 가정해 봅시다. 이는 (이 호출에서) T와 u32가 실제로는 같은 타입으로 밝혀진다는 뜻이므로, 결국에는 같은 ty::Ty에 이르게 되지만, rustc_hir::Ty는 서로 다릅니다. (다만 이는 약간 지나치게 단순화한 설명입니다. 타입 검사 중에는 함수를 제네릭하게 검사하므로 여전히 u32와 구별되는 T를 가지기 때문입니다. 나중에 코드 생성을 할 때는 항상 각 함수의 “단형화된”(완전히 치환된) 버전을 다루게 되므로, T가 무엇을 나타내는지(구체적으로는 그것이 u32라는 것을) 알게 됩니다.)
예를 하나 더 들어 보겠습니다.
#![allow(unused)]
fn main() {
mod a {
type X = u32;
pub fn foo(x: X) -> u32 { 22 }
}
mod b {
type X = i32;
pub fn foo(x: X) -> i32 { x }
}
}
여기서 타입 X는 당연히 맥락에 따라 달라집니다. rustc_hir::Ty를 살펴보면 두 경우 모두 X가 별칭이라는 결과를 얻습니다(다만 이름 해석을 거쳐 서로 다른 별칭으로 매핑됩니다). 그러나 ty::Ty 시그니처를 살펴보면 fn(u32) -> u32 또는 fn(i32) -> i32 중 하나가 됩니다(타입 별칭은 완전히 전개됩니다).
ty::Ty 구현
rustc_middle::ty::Ty는 실제로는 Interned<WithCachedTypeInfo<TyKind>>를 감싸는 래퍼입니다. 일반적으로 Interned는 무시해도 됩니다. 이것에 명시적으로 접근할 일은 사실상 없습니다. 우리는 항상 이들을 Ty 안에 숨기고 Deref impl이나 메서드를 통해 건너뜁니다. TyKind는 다양한 Rust 타입(예: 기본 타입, 참조, 대수적 데이터 타입, 제네릭, 라이프타임 등)을 나타내는 배리언트를 가진 큰 열거형입니다. WithCachedTypeInfo에는 flags와 outer_exclusive_binder 같은 몇 가지 캐시된 값이 있습니다. 이 값들은 효율을 위한 편리한 방편으로, 우리가 알고 싶어 할 만한 타입 정보를 요약해 두지만, 여기서는 크게 중요하지 않습니다. 마지막으로 Interned 덕분에 ty::Ty는 얇은 포인터와 같은 타입이 될 수 있습니다. 이를 통해 인터닝의 다른 이점과 더불어 동등성을 저렴하게 비교할 수 있습니다.
타입 할당 및 다루기
새 타입을 할당하려면 Ty에 정의된 다양한 new_* 메서드를 사용할 수 있습니다. 이 메서드들의 이름은 대부분 여러 종류의 타입에 대응합니다. 예를 들면 다음과 같습니다.
let array_ty = Ty::new_array_with_const_len(tcx, ty, count);
이 메서드들은 모두 Ty<'tcx>를 반환합니다. 돌려받는 라이프타임은 이 tcx가 접근할 수 있는 아레나의 라이프타임이라는 점에 유의하십시오. 타입은 항상 정준화되고 인터닝됩니다(따라서 완전히 같은 타입을 두 번 할당하는 일은 없습니다).
tcx 자체의 필드에 접근하여 여러 공통 타입을 찾을 수도 있습니다. tcx.types.bool, tcx.types.char 등이 그 예입니다. (자세한 내용은 CommonTypes를 참고하십시오.)
타입 비교
타입은 인터닝되므로 ==를 사용해 동등성을 효율적으로 비교할 수 있습니다. 그러나 해싱을 하면서 중복을 찾는 경우가 아니라면, 이는 거의 항상 원하는 동작이 아닙니다. Rust에서는 같은 타입을 표현하는 방법이 여러 가지인 경우가 많고, 특히 추론이 관여하면 더욱 그렇기 때문입니다.
예를 들어, 타입 {integer}(정수 추론 변수인 ty::Infer(ty::IntVar(..))로, 0 같은 정수 리터럴의 타입입니다)와 u8(ty::UInt(..))은 서로 대입할 수 있는지 검사할 때(진단 코드에서 흔한 작업입니다) 같은 것으로 취급해야 하는 경우가 많습니다. 하지만 둘은 서로 다른 타입이므로 이들에 대한 ==는 false를 반환합니다.
두 타입을 올바르게 비교하는 가장 간단한 방법에는 추론 컨텍스트(infcx)가 필요합니다. 추론 컨텍스트가 있다면 infcx.can_eq(param_env, ty1, ty2)를 사용하여 두 타입을 같게 만들 수 있는지 확인할 수 있습니다. 진단 과정에서 확인하려는 것은 대개 이것입니다. 진단은 두 타입이 컴파일러의 타입 검사 계층에서 동일하게 표현되는지가 아니라, 서로 대입될 수 있는지와 같은 질문에 관심을 두기 때문입니다.
추론 컨텍스트를 다룰 때는 타입 안에 있을 수 있는 추론 변수가 실제로 그 추론 컨텍스트에 속하는지 주의 깊게 확인해야 합니다. 이미 추론 컨텍스트에 접근할 수 있는 함수 안에 있다면 이 조건은 충족되어 있을 것입니다. 구체적으로는 HIR 타입 검사나 MIR 빌림 검사 중에 이러한 경우에 해당합니다.
또 다른 고려 사항은 정규화입니다. 두 타입이 실제로는 같지만, 한쪽이 연관 타입 뒤에 가려져 있을 수 있습니다. 이들을 올바르게 비교하려면 먼저 타입을 정규화해야 합니다. 이는 주로 HIR 타입 검사 중에, 그리고 TyCtxt 쿼리에서 얻은 모든 타입(예: tcx.type_of()에서 얻은 타입)에서 문제가 됩니다.
타입 검사 중에 FnCtxt나 ObligationCtxt를 사용할 수 있다면, 타입을 정규화할 때 이들에 대해 .normalize(ty)를 사용해야 합니다. 타입 검사가 끝난 뒤에는 진단 코드에서 tcx.normalize_erasing_regions(ty)를 사용할 수 있습니다.
Ty에 ==를 사용해도 괜찮은 경우도 있습니다. 예를 들어 후기 린트(late lint)나 단형화(monomorphization) 이후가 그렇습니다. 이 시점에는 타입 검사가 완료되어 모든 추론 변수가 해결되었고 모든 리전이 지워졌기 때문입니다. 이러한 경우 추론 변수나 정규화가 문제가 되지 않는다는 것을 알고 있다면, 해당 린트에 #[allow]나 #[expect]를 적용하는 것을 권장합니다.
진단 코드에서 추론 컨텍스트에 접근할 수 없는 경우, (타입 검사 중처럼) 어딘가에서 추론 컨텍스트를 사용할 수 있다면 함수 호출을 거쳐 전달해야 합니다.
추론 컨텍스트를 전혀 사용할 수 없다면 type-inference에서 설명한 대로 새로 만들 수 있습니다. 하지만 이는 관련된 타입(예를 들어 tcx.type_of() 같은 쿼리에서 얻은 타입)을 fresh_args_for_item으로 새 추론 변수로 실제로 치환한 경우에만 유용합니다. 이를 이용해 “임의의 T에 대해 Vec<T>를 Vec<u32>와 단일화할 수 있는가?” 같은 질문에 답할 수 있습니다.
ty::TyKind 배리언트
참고: TyKind는 함수형 프로그래밍의 Kind 개념이 아닙니다.
컴파일러에서 Ty를 다룰 때는 흔히 타입의 종류에 대해 패턴 매칭을 합니다.
fn foo(x: Ty<'tcx>) {
match x.kind {
...
}
}
kind 필드의 타입은 TyKind<'tcx>이며, 이는 컴파일러의 모든 타입 종류를 정의하는 열거형입니다.
참고: 타입 추론 중에 타입의
kind필드를 검사하는 것은 위험할 수 있습니다. 고려해야 할 추론 변수나 다른 요소가 있을 수 있고, 때로는 타입이 아직 알려지지 않았다가 나중에 알려지기도 하기 때문입니다.
관련된 타입이 많으며, 이에 대해서는 차차 다루겠습니다(예: 리전/라이프타임, “치환” 등).
TyKind 열거형에는 많은 배리언트가 있으며, 문서에서 확인할 수 있습니다. 다음은 그 일부입니다.
- 대수적 데이터 타입(ADT) 대수적 데이터 타입은
struct,enum또는union입니다. 내부적으로struct,enum,union은 사실 같은 방식으로 구현되며, 모두ty::TyKind::Adt입니다. 기본적으로 사용자 정의 타입입니다. 이에 대해서는 나중에 더 자세히 설명하겠습니다. - Foreign
extern type T에 해당합니다. - Str str 타입입니다. 사용자가
&str를 작성하면, 그 타입의str부분을Str로 표현합니다. - 슬라이스는
[T]에 해당합니다. - 배열은
[T; n]에 해당합니다. - RawPtr
*mut T또는*const T에 해당합니다. - Ref
Ref는 안전한 참조인&'a mut T또는&'a T를 나타냅니다.Ref에는 몇 가지 연관된 부분이 있는데, 예를 들어 참조가 가리키는 타입인Ty<'tcx>가 있습니다.Region<'tcx>는 참조의 라이프타임 또는 리전이며,Mutability는 참조가 가변인지 여부를 나타냅니다. - Param 타입 매개변수를 나타냅니다(예:
Vec<T>의T). - Error 더 나은 진단을 출력할 수 있도록 어딘가에 있는 타입 오류를 나타냅니다. 이에 대해서는 나중에 더 자세히 논의하겠습니다.
- 그 밖에도 많습니다…
임포트 규칙
엄격하게 정해진 규칙은 없지만, ty 모듈은 보통 다음과 같이 사용합니다:
use ty::{self, Ty, TyCtxt};
특히 Ty 타입과 TyCtxt 타입은 매우 자주 쓰이므로 직접 임포트합니다. 그 밖의 타입은 흔히 명시적인 ty:: 접두사를 붙여 참조합니다(예: ty::TraitRef<'tcx>). 다만 일부 모듈은 더 많거나 더 적은 이름을 명시적으로 임포트하기도 합니다.
타입 오류
사용자가 타입 오류를 일으켰을 때 생성되는 TyKind::Error가 있습니다. 이 타입을 전파하고 이로 인해 발생하는 다른 오류를 억제하여, 연쇄적인 컴파일러 오류 메시지로 사용자를 압도하지 않으려는 것이 그 의도입니다.
TyKind::Error에는 중요한 불변 조건이 있습니다. 컴파일러는 오류가 이미 사용자에게 보고되었음을 알고 있는 경우가 아니라면 Error를 절대 생성해서는 안 됩니다. 이는 보통 (a) 바로 그 자리에서 방금 오류를 보고했거나 (b) 기존의 Error 타입을 전파하고 있기 때문입니다(이 경우 해당 오류 타입이 생성될 때 오류가 이미 보고되었어야 합니다).
이 불변 조건을 유지하는 것이 중요한 이유는 Error 타입의 핵심 목적이 다른 오류를 억제하는 것, 즉 그 오류들을 보고하지 않는 것이기 때문입니다. 실제로 사용자에게 오류를 내보내지 않은 채 Error 타입을 생성하면 이후의 오류들이 억제될 수 있고, 컴파일이 의도치 않게 성공할 수도 있습니다!
때로는 세 번째 경우도 있습니다. 오류가 보고되었다고 생각하지만, 그 오류가 이 자리가 아니라 컴파일의 더 이른 단계에서 보고되었으리라 생각하는 경우입니다. 이 경우 delayed_bug 또는 span_delayed_bug로 “지연된 버그(delayed bug)“를 생성할 수 있습니다. 이렇게 하면 컴파일이 오류를 낼 것으로 예상한다는 기록이 남으며, 만약 컴파일이 성공하면 컴파일러 버그 보고가 발생합니다.
안전성을 높이기 위해, 실제로 rustc_middle::ty 외부에서는 TyKind::Error 값을 생성할 수 없습니다. TyKind::Error에는 다른 곳에서 생성되지 못하도록 막는 비공개 멤버가 있습니다. 대신 Ty::new_error 또는 Ty::new_error_with_message 메서드를 사용해야 합니다. 이 메서드들은 ErrorGuaranteed를 인자로 받거나, 종류가 Error인 인턴된 Ty를 반환하기 전에 span_delayed_bug를 호출합니다. 이미 span_delayed_bug를 사용하려던 참이었다면, 중복된 지연 버그를 피하기 위해 대신 span과 메시지를 ty_error_with_message에 넘기기만 하면 됩니다.
TyKind 배리언트 축약 구문
Ty의 디버그 출력을 살펴보거나 단순히 컴파일러의 여러 타입에 관해 이야기할 때, 유효한 Rust는 아니지만 타입에 관한 내부 정보를 간결하게 나타내기 위해 사용되는 구문을 접할 수 있습니다. 다음은 여러 구문이 실제로 무엇을 의미하는지 알려 주는 간단한 참조용 치트 시트입니다.
- 제네릭 매개변수:
{name}/#{index}예:T/#0. 여기서index는 제네릭 매개변수 목록에서의 위치에 해당합니다 - 추론 변수:
?{id}예:?x/?0. 여기서id는 추론 변수를 식별합니다 - 바인더의 변수:
^{binder}_{index}예:^0_x/^0_2. 여기서binder와index는 어느 바인더의 어느 변수를 가리키는지 식별합니다 - 플레이스홀더:
!{id}또는!{id}_{universe}예:!x/!0/!x_2/!0_2. 지정된 유니버스 안의 어떤 고유한 타입을 나타냅니다. 유니버스가0일 때는 흔히 생략됩니다
이 내용은 이후 장에서 더 자세히 다룹니다.