타입 추론
타입 추론은 표현식의 타입을 자동으로 알아내는 과정입니다.
타입 추론 덕분에 Rust는 타입 표기를 적게 쓰거나 전혀 쓰지 않아도 동작할 수 있으므로, 사용자가 더 편하게 코드를 작성할 수 있습니다.
fn main() {
let mut things = vec![];
things.push("thing");
}
여기서 things의 타입은 things에 넣는 값 때문에 Vec<&str>로 추론됩니다.
이 타입 추론은 표준 Hindley-Milner(HM) 타입 추론 알고리즘을 기반으로 하지만, 서브타이핑, 영역 추론, 고차 랭크 타입(higher-ranked type)을 지원하기 위해 여러 방식으로 확장되었습니다.
용어에 관한 참고 사항
추론 변수는 ?T 표기법으로 나타냅니다. 추론 변수는 존재 변수라고도 부릅니다.
“영역(region)“과 “라이프타임“이라는 용어는 같은 뜻으로 사용합니다. 둘 다 &'a T의 'a를 가리킵니다.
“바운드 영역“이라는 용어는 for<'a> fn(&'a u32)의 'a처럼 함수 시그니처에서 바인딩된 영역을 가리킵니다. 바인딩되지 않은 영역은 “자유(free)” 영역이라고 합니다.
추론 컨텍스트 생성하기
추론 컨텍스트는 다음과 같은 방식으로 생성합니다.
let infcx = tcx.infer_ctxt().build();
// Use the inference context `infcx` here.
infcx의 타입은 InferCtxt<'tcx>이며, 이를 만드는 데 사용한 tcx와 같은 'tcx 라이프타임을 가집니다.
tcx.infer_ctxt 메서드는 실제로는 빌더를 반환합니다. 따라서 infcx를 생성하기 전에 몇 가지 설정을 할 수 있습니다. 자세한 내용은 InferCtxtBuilder를 참고하십시오.
추론 변수
추론 컨텍스트의 주된 목적은 여러 추론 변수를 담아 두는 것입니다. 추론 변수는 정확한 값이 아직 알려지지 않았지만 타입 검사를 진행하면서 밝혀질 타입이나 영역을 나타냅니다.
H-M 타입 시스템이나 Prolog 같은 논리 언어에서 쓰는 단일화(unification)의 기본 개념을 알고 있다면, 이것도 같은 개념입니다. 그렇지 않다면 H-M 타입 추론의 동작 방식을 다루는 튜토리얼이나 Chalk 프로젝트의 단일화에 관한 이 블로그 글을 읽어 보는 것이 좋습니다.
종합하면, 추론 컨텍스트는 다섯 종류의 추론 변수를 저장합니다( 2023년 3월 기준):
- 타입 변수는 세 종류로 나뉩니다.
- 일반 타입 변수(가장 흔한 종류)입니다. 이 변수는 어떤 타입과도 단일화될 수 있습니다.
- 정수 타입 변수는 정수 타입과만 단일화될 수 있으며,
22같은 정수 리터럴 표현식에서 생겨납니다. - 부동소수점 타입 변수는 부동소수점 타입과만 단일화될 수 있으며,
22.0같은 부동소수점 리터럴 표현식에서 생겨납니다.
- 영역 변수는 라이프타임을 나타내며, 곳곳에서 생겨납니다.
- 상수 변수는 상수를 나타냅니다.
모든 타입 변수는 거의 같은 방식으로 동작합니다. 새 타입 변수를 생성하면, 아직 해결되지 않은 타입 ?T를 나타내는 Ty<'tcx>를 얻습니다. 그런 다음 동등성이나 서브타이핑처럼 추론기가 지원하는 여러 연산을 적용할 수 있으며, 그 결과로 추론기가 해당 ?T를 특정 값으로 인스턴스화(또는 바인딩)할 수도 있습니다.
영역 변수는 다소 다르게 동작하며, 아래의 별도 절에서 설명합니다.
동등성 / 서브타이핑 강제하기
타입 추론기에서 수행할 수 있는 가장 기본적인 연산은 동등성이며, 이는 두 타입 T와 U가 같도록 강제합니다. 동등성 제약을 추가하는 권장 방법은 대략 다음과 같이 at 메서드를 사용하는 것입니다.
infcx.at(...).eq(t, u);
첫 번째 at() 호출은 약간의 맥락, 즉 이 단일화(unification)를 왜 수행하는지와 어떤 환경에서 수행하는지를 제공하며, eq 메서드가 실제 동등성 제약을 수행합니다.
무언가를 동일시하면 그것들이 정확히 같도록 강제하게 됩니다. 동일시는 InferResult를 반환합니다. Err(err)를 반환하면 동일시가 실패한 것이며, 감싸고 있는 TypeError가 무엇이 잘못되었는지 알려 줍니다.
성공하는 경우가 아마 더 흥미로울 것입니다. eq의 “주된” 반환 타입은 ()입니다. 즉, 성공하더라도 특별히 관심을 가질 만한 값을 반환하지 않습니다. 오히려 이 메서드는 타입 변수를 제약하는 등의 부수 효과를 위해 실행됩니다. 그러나 실제 반환 타입은 ()가 아니라 InferOk<()>입니다. InferOk 타입은 추가적인 트레이트 의무(obligation)를 전달하는 데 사용됩니다. 이러한 의무가 이행되도록 보장하는 것은 여러분의 몫입니다(일반적으로 이행 컨텍스트(fulfillment context)에 등록하는 방식으로 합니다). 이에 대한 더 자세한 배경은 트레이트 장을 참조하십시오.
마찬가지로 infcx.at(..).sub(..)를 통해 서브타이핑을 강제할 수 있습니다. 위와 동일한 기본 개념이 적용됩니다.
동등성 “시도하기”
때로는 두 타입을 오류 없이 동일시하는 것이 가능한지 알고 싶을 때가 있습니다. 이는 infcx.can_eq(서브타이핑의 경우 infcx.can_sub)로 테스트할 수 있습니다. 이것이 Ok를 반환하면 동등성이 가능한 것입니다. 하지만 어떤 경우든 모든 부수 효과는 되돌려집니다.
다만 이러한 메서드의 성공 또는 실패는 항상 영역을 제외한(modulo regions) 것이라는 점에 유의하십시오. 즉, 두 타입 &'a u32와 &'b u32는 'a != 'b인 경우에도 can_eq에 대해 Ok를 반환합니다. 이는 영역 제약을 해결하는 방식이 “2단계“로 이루어진다는 특성에서 비롯됩니다.
스냅숏
앞의 can_eq 절에서 설명했듯이, 일련의 연산을 수행한 뒤 그 부수 효과를 되돌릴 수 있으면 유용한 경우가 많습니다. 이렇게 하는 이유는 여러 가지입니다. 그중 하나는 어떤 경로를 택할지 정하기 전에 여러 가능성을 시도해 보면서 백트래킹할 수 있도록 하기 위함입니다. 또 다른 이유는 일련의 작은 변경이 원자적으로 일어나거나 전혀 일어나지 않도록 보장하기 위함입니다.
이를 가능하게 하기 위해 추론 컨텍스트는 snapshot 메서드를 지원합니다. 이 메서드를 호출하면 여러분이 수행하는 연산에서 발생하는 변경 사항을 기록하기 시작합니다. 작업을 마치면 해당 변경 사항을 취소하는 rollback_to를 호출하거나, 변경 사항을 영구적으로 만드는 confirm을 호출할 수 있습니다. 스택과 같은 규칙을 따르기만 하면 스냅숏은 중첩될 수 있습니다.
스냅숏을 직접 사용하기보다는 더 높은 수준의 패턴을 캡슐화한 commit_if_ok나 probe 같은 메서드를 사용하는 것이 도움이 되는 경우가 많습니다.
서브타이핑 의무
짚어 볼 만한 것 중 하나는 서브타이핑 의무입니다. ?T <: i32처럼 두 타입 사이에 서브타입 관계를 강제하는 경우, 이를 동등성 제약으로 변환할 수 있는 경우가 많습니다. 이는 Rust의 서브타이핑 개념이 상당히 제한적이기 때문입니다. 따라서 위의 경우 ?T <: i32는 ?T = i32와 동등합니다.
그러나 어떤 경우에는 더 신중해야 합니다. 예를 들어 영역이 관련된 경우가 그렇습니다. 따라서 ?T <: &'a i32가 있다면, 먼저 &'a i32를 영역 변수를 가진 타입인 &'?b i32로 “일반화“한 다음, ?T를 그 타입과 단일화합니다(?T = &'?b i32). 그런 다음 이 새 변수를 원래의 경계와 관계 짓습니다:
&'?b i32 <: &'a i32
그 결과 '?b: 'a라는 영역 제약(아래 참조)이 생깁니다.
마지막으로 흥미로운 경우는 ?T <: ?U처럼 바인딩되지 않은 두 타입 변수를 관계 짓는 경우입니다. 이 경우에는 더 진행할 수 없으므로 Subtype(?T, ?U)라는 의무를 큐에 넣고 InferOk 메커니즘을 통해 반환합니다. ?T 또는 ?U에 대해 더 자세한 정보가 알려지면 다시 시도해야 합니다.
영역 제약
영역은 타입과는 다소 다른 방식으로 추론됩니다. 즉시 단일화하는 대신 진행하면서 제약을 수집하기만 하며, 영역을 풀려는 시도는 (거의) 하지 않습니다. 이러한 제약은 “outlives” 제약의 형태를 가집니다:
'a: 'b
실제 코드는 이를 하위 영역 관계로 보는 경향이 있지만, 같은 개념입니다:
'b <= 'a
(“verifys“와 같은 다양한 다른 종류의 제약도 있습니다. 자세한 내용은 region_constraints 모듈을 참조하십시오.)
어느 정도 즉시 단일화를 수행하는 경우가 하나 있습니다. 두 영역 사이에 동등성 제약이 있다면
'a = 'b
그 사실을 단일화 테이블에 기록합니다. 그런 다음 opportunistic_resolve_var를 사용하여 'b를 'a로(또는 그 반대로) 변환할 수 있습니다. 이는 고정점 알고리즘의 종료를 보장하기 위해 필요할 때가 있습니다.
영역 제약 풀기
영역 제약은 다른 모든 제약이 알려지고 다른 모든 의무가 증명된 후, 타입 검사의 맨 마지막에야 풀립니다. 현재 영역 제약을 푸는 방법에는 어휘적(lexical) 방법과 비어휘적(non-lexical) 방법의 두 가지가 있습니다. 결국에는 하나만 남게 될 것입니다.
여기서 예외는 leak-check로, 트레이트 해결 중에 사용되며 고차 영역을 포함하는 영역 제약에 의존합니다. 루트 유니버스의 영역 제약(즉, for<'a>에서 비롯되지 않은 것)은 트레이트 시스템에 영향을 주어서는 안 됩니다. 이러한 영역은 코드 생성 중에 모두 지워지기 때문입니다.
어휘적 영역 제약을 풀려면 resolve_regions_and_report_errors를 호출하십시오. 이는 영역 제약 과정을 “닫고” lexical_region_resolve 코드를 호출합니다. 이 작업이 끝난 뒤에 동등화하거나 서브타이핑 관계를 만들려고 하면 모두 ICE가 발생합니다.
NLL 솔버(실제로는 MIR 타입 검사기)는 약간 다른 방식으로 동작합니다. 이 검사기는 트레이트 해결에 정준 쿼리를 사용하며, 정준 쿼리는 마지막에 take_and_reset_region_constraints를 사용합니다. 이는 정준 쿼리 중에 추가된 모든 outlives 제약을 추출합니다. NLL 솔버는 어떤 영역이 서로보다 오래 사는지뿐만 아니라 어디에서 그러한지도 알아야 하므로 이 작업이 필요합니다. 마지막으로 NLL 솔버는 get_region_var_infos를 호출하여 모든 영역 변수를 솔버에 제공합니다.
어휘 영역 해석
어휘 영역 해석은 먼저 각 영역 변수에 빈 값을 할당하는 것으로 시작합니다. 그런 다음 각 outlives 제약 조건을 반복적으로 처리하면서 고정점에 도달할 때까지 영역 변수를 확장합니다. 영역 변수는 영역 격자(lattice)에서의 최소 상계(least-upper-bound) 관계를 사용하여 비교적 간단한 방식으로 확장할 수 있습니다.