이름 해석
이전 장들에서는 모든 매크로가 확장된 상태로 추상 구문 트리(AST)가 어떻게 만들어지는지 살펴보았습니다. 또한 이를 위해 임포트와 매크로 이름을 해석하는 이름 해석 작업이 어느 정도 필요하다는 것도 살펴보았습니다. 이 장에서는 이것이 실제로 어떻게 이루어지는지와 그 밖의 내용을 설명합니다.
사실 매크로 확장 중에는 완전한 이름 해석을 하지 않습니다. 그 시점에는 임포트와 매크로만 해석합니다. 이는 무엇을 확장해야 하는지 알기 위해서라도 필요합니다. 이후 전체 AST가 갖춰지면, 크레이트의 모든 이름을 해석하기 위해 완전한 이름 해석을 수행합니다. 이 작업은 rustc_resolve::late에서 이루어집니다. 매크로 확장 중과 달리, 이 후기 확장에서는 새로운 이름이 추가될 수 없으므로 이름 해석을 한 번만 시도하면 됩니다. 이름 해석에 실패하면 컴파일러 오류가 됩니다.
이름 해석은 복잡합니다. 서로 다른 네임스페이스(예: 매크로, 값, 타입, 라이프타임)가 있으며, 이름은 서로 다른 (중첩된) 스코프에서 유효할 수 있습니다. 또한 이름의 종류에 따라 해석에 실패하는 방식이 다를 수 있고, 스코프에 따라 실패가 일어나는 방식도 다를 수 있습니다. 예를 들어 모듈 스코프에서 실패란 해당 모듈에 확장되지 않은 매크로도, 해석되지 않은 글롭 임포트도 없다는 것을 의미합니다. 반면 함수 본문 스코프에서 실패하려면 이름이 현재 블록, 모든 바깥 스코프, 그리고 전역 스코프 어디에도 없어야 합니다.
기본 사항
프로그램에서는 변수, 타입, 함수 등에 이름을 붙여서 이를 참조합니다. 이러한 이름이 항상 고유한 것은 아닙니다. 예를 들어 다음과 같은 유효한 Rust 프로그램을 봅시다.
#![allow(unused)]
fn main() {
type x = u32;
let x: x = 1;
let y: x = 2;
}
3번째 줄에서 x가 타입(u32)인지 값(1)인지 어떻게 알 수 있습니까? 이러한 충돌은 이름 해석 과정에서 해결됩니다. 이 경우 이름 해석은 타입 이름과 변수 이름이 별도의 네임스페이스에 존재하므로 공존할 수 있다고 정의합니다.
Rust의 이름 해석은 두 단계로 이루어진 과정입니다. 첫 번째 단계는 macro 확장 중에 실행되며, 이 단계에서 모듈 트리를 빌드하고 import를 해석합니다. 매크로 확장과 이름 해석은 ResolverAstLoweringExt 트레이트를 통해 서로 통신합니다.
두 번째 단계의 입력은 입력 파일을 파싱하고 macros를 확장하여 만들어진 구문 트리입니다. 이 단계에서는 소스에 있는 모든 이름에서 해당 이름이 도입된 관련 위치로 향하는 링크를 만듭니다. 또한 오타 수정 제안, 임포트할 트레이트, 사용되지 않는 항목에 대한 린트 등 유용한 오류 메시지도 생성합니다.
두 번째 단계(Resolver::resolve_crate)가 성공적으로 실행되면, 나머지 컴파일 과정에서 (hir::lowering::Resolver 인터페이스를 통해) 현재 존재하는 이름에 대해 질의할 때 사용할 수 있는 일종의 인덱스가 만들어집니다.
이름 해석은 rustc_resolve 크레이트에 있으며, 대부분은 lib.rs에 있고 일부 헬퍼나 심볼 종류별 로직은 다른 모듈에 있습니다.
네임스페이스
서로 다른 종류의 심볼은 서로 다른 네임스페이스에 존재합니다 ‒ 예를 들어 타입은 변수와 충돌하지 않습니다. 변수는 소문자로, 타입은 대문자로 시작하므로 보통은 이런 일이 일어나지 않지만, 이는 관례일 뿐입니다. 다음은 (경고와 함께) 컴파일되는 올바른 Rust 코드입니다:
#![allow(unused)]
fn main() {
type x = u32;
let x: x = 1;
let y: x = 2; // See? x is still a type here.
}
이 문제와 더불어 이러한 네임스페이스마다 조금씩 다른 스코프 규칙에 대처하기 위해, 해석기는 네임스페이스들을 분리해 두고 각각에 대해 별도의 구조를 빌드합니다.
다시 말해, 코드에서 네임스페이스를 언급할 때 이는 모듈 계층 구조가 아니라 타입, 값, 매크로의 구분을 뜻합니다.
스코프와 립(rib)
이름은 소스 코드의 특정 영역에서만 보입니다. 이는 계층 구조를 이루지만 반드시 단순한 구조인 것은 아닙니다 ‒ 한 스코프가 다른 스코프의 일부라고 해서, 바깥쪽 스코프에서 보이는 이름이 안쪽 스코프에서도 보이거나 같은 대상을 가리킨다는 뜻은 아닙니다.
이에 대처하기 위해 컴파일러는 Rib이라는 개념을 도입합니다. 이는 스코프를 추상화한 것입니다. 보이는 이름의 집합이 바뀔 가능성이 있을 때마다 새로운 Rib이 스택에 푸시됩니다. 이런 일이 일어날 수 있는 곳의 예는 다음과 같습니다:
- 명백한 곳 ‒ 블록을 감싸는 중괄호, 함수 경계, 모듈.
let바인딩 도입 ‒ 이는 같은 이름을 가진 다른 바인딩을 가릴 수 있습니다.- 매크로 확장 경계 ‒ 매크로 위생(hygiene)에 대처하기 위함입니다.
이름을 찾을 때는 ribs 스택을 가장 안쪽부터 바깥쪽으로 순회합니다. 이렇게 하면 이름의 가장 가까운 의미(다른 무엇에도 가려지지 않은 의미)를 찾을 수 있습니다. 바깥쪽 Rib으로 넘어가는 것은 어떤 이름을 사용할 수 있는지에도 영향을 줄 수 있습니다 ‒ 중첩된 함수(클로저가 아닌)가 있으면, 일반적인 스코프 규칙에 따르면 보여야 하는데도 안쪽 함수는 바깥쪽 함수의 매개변수와 지역 바인딩에 접근할 수 없습니다. 예시는 다음과 같습니다.
#![allow(unused)]
fn main() {
fn do_something<T: Default>(val: T) { // <- New rib in both types and values (1)
// `val` is accessible, as is the helper function
// `T` is accessible
let helper = || { // New rib on the block (2)
// `val` is accessible here
}; // End of (2), new rib on `helper` (3)
// `val` is accessible, `helper` variable shadows `helper` function
fn helper() { // <- New rib in both types and values (4)
// `val` is not accessible here, (4) is not transparent for locals
// `T` is not accessible here
} // End of (4)
let val = T::default(); // New rib (5)
// `val` is the variable, not the parameter here
} // End of (5), (3) and (1)
}
네임스페이스마다 규칙이 조금씩 다르기 때문에, 각 네임스페이스는 다른 네임스페이스와 병렬로 구성되는 독립적인 Rib 스택을 가집니다. 또한 지역 레이블(예: 루프나 블록의 이름)을 위한 Rib 스택도 있는데, 이는 그 자체로 완전한 네임스페이스는 아닙니다.
전체 전략
크레이트 전체의 이름 해석을 수행하기 위해, 구문 트리를 위에서 아래로 순회하며 만나는 모든 이름을 해석합니다. 이름이 사용되는 시점에는 이미 그 이름이 Rib 계층 구조에 도입되어 있으므로, 대부분의 이름에 대해서는 이 방식이 잘 동작합니다.
여기에는 몇 가지 예외가 있습니다. 아이템은 마주치기 전에도 사용될 수 있으므로 조금 까다롭습니다 ‒ 따라서 모든 블록은 먼저 아이템을 훑어서 해당 블록의 Rib을 채워야 합니다.
이보다 더 까다로운 것으로는 재귀적인 고정점(fixed-point) 해석이 필요한 임포트와, 나머지 코드를 처리하기 전에 해석하고 확장해야 하는 매크로가 있습니다.
따라서 이름 해석은 여러 단계에 걸쳐 수행됩니다.
추측적 크레이트 로딩
유용한 오류를 제공하기 위해, rustc는 경로를 찾을 수 없으면 해당 경로를 스코프로 가져오도록 제안합니다. 이것은 어떻게 이루어집니까? rustc는 모든 크레이트의 모든 모듈을 살펴보며 일치할 가능성이 있는 항목을 찾습니다. 여기에는 아직 로드되지 않은 크레이트까지 포함됩니다!
아직 로드되지 않은 크레이트를 import 제안에 포함하기 위해 크레이트를 미리 로드하는 것을 추측성 크레이트 로딩이라고 부릅니다. 이 과정에서 발생하는 오류는 보고되어서는 안 되기 때문입니다. 크레이트를 로드하기로 결정한 것은 사용자가 아니라 rustc_resolve입니다. 이 작업을 수행하는 함수는 lookup_import_candidates이며, rustc_resolve::diagnostics에 있습니다.
추측성 로드와 사용자가 시작한 로드를 구별하기 위해 rustc_resolve는 record_used 매개변수를 전달하며, 이 값은 로드가 추측성일 때 false입니다.
TODO: #16
이 내용은 코드를 처음 훑어보며 학습한 결과입니다. 분명히 불완전하며 충분히 상세하지 않습니다. 또한 일부 부정확한 부분이 있을 수 있습니다. 그럼에도 불구하고, 그 안에서 어떤 일이 일어나는지 파악하는 데 유용한 첫 이정표는 될 것입니다.
- 정확히 무엇과 연결되며, 그 결과는 이후의 컴파일 단계에 어떻게 공개되고 사용됩니까?
- 누가 이것을 호출하며, 실제로 어떻게 사용됩니까?
- 한 번의 패스로 실행된 뒤 그 결과만 사용됩니까, 아니면 증분적으로 계산될 수 있습니까?
- 전반적인 전략에 대한 설명이 다소 모호합니다.
Rib라는 이름은 어디에서 유래했습니까?- 이 부분에는 자체 테스트가 있습니까, 아니면 일부 e2e 테스트의 일부로만 테스트됩니까?