클로저 캡처 추론
이 절에서는 rustc가 클로저를 처리하는 방식을 설명합니다. Rust의 클로저는 실질적으로, 클로저를 생성한 쪽의 스택 프레임에서 가져와 사용하는 값(또는 그 값에 대한 참조)을 담는 구조체로 “디슈가링“됩니다. rustc는 클로저가 어떤 값을 어떻게 사용하는지 파악해야 합니다. 그래야 주어진 변수를 공유 참조로 캡처할지, 가변 참조로 캡처할지, 아니면 이동(move)으로 캡처할지 결정할 수 있습니다. 또한 rustc는 클로저가 클로저 트레이트(Fn, FnMut, FnOnce) 가운데 어느 것을 구현할 수 있는지도 파악해야 합니다.
몇 가지 예제부터 살펴봅시다.
예제 1
먼저 다음 예제의 클로저가 어떻게 디슈가링되는지 살펴봅시다.
fn closure(f: impl Fn()) {
f();
}
fn main() {
let x: i32 = 10;
closure(|| println!("Hi {}", x)); // The closure just reads x.
println!("Value of x after return {}", x);
}
위 코드가 immut.rs라는 파일의 내용이라고 가정하겠습니다. 다음 명령으로 immut.rs를 컴파일한다고 합시다. -Z dump-mir=all 플래그를 사용하면 rustc가 MIR을 생성하여 mir_dump라는 디렉터리에 덤프합니다.
> rustc +stage1 immut.rs -Z dump-mir=all
이 명령을 실행하면 현재 작업 디렉터리에 mir_dump라는 디렉터리가 새로 생성되며, 그 안에 여러 파일이 들어 있습니다. rustc.main.-------.mir_map.0.mir 파일을 살펴보면 여러 내용 가운데 다음 줄도 들어 있음을 확인할 수 있습니다.
_4 = &_1;
_3 = [closure@immut.rs:7:13: 7:36] { x: move _4 };
이 장의 MIR 예제에서 _1은 x라는 점에 유의하십시오.
여기 첫 번째 줄 _4 = &_1;에서 mir_dump는 x가 불변 참조로 빌려졌음을 알려 줍니다. 클로저가 x를 읽기만 하므로 이는 기대한 결과입니다.
예제 2
다른 예제를 살펴보겠습니다.
fn closure(mut f: impl FnMut()) {
f();
}
fn main() {
let mut x: i32 = 10;
closure(|| {
x += 10; // The closure mutates the value of x
println!("Hi {}", x)
});
println!("Value of x after return {}", x);
}
_4 = &mut _1;
_3 = [closure@mut.rs:7:13: 10:6] { x: move _4 };
이번에는 _4 = &mut _1; 줄에서 빌림이 가변 빌림으로 바뀐 것을 볼 수 있습니다. 당연한 결과입니다! 클로저가 x를 10만큼 증가시키기 때문입니다.
예제 3
예제를 하나 더 살펴보겠습니다.
fn closure(f: impl FnOnce()) {
f();
}
fn main() {
let x = vec![21];
closure(|| {
drop(x); // Makes x unusable after the fact.
});
// println!("Value of x after return {:?}", x);
}
_6 = [closure@move.rs:7:13: 9:6] { x: move _1 }; // bb16[3]: scope 1 at move.rs:7:13: 9:6
여기서는 x가 클로저 안으로 직접 이동되므로, 클로저 이후에는 x에 접근할 수 없습니다.
컴파일러의 추론
이제 rustc 코드를 깊이 들여다보며 컴파일러가 이러한 추론을 모두 어떻게 수행하는지 살펴봅시다.
이후 논의에서 자주 사용할 용어인 upvar를 정의하는 것부터 시작합시다. upvar는 클로저가 정의된 함수에 지역적인 변수입니다. 따라서 위 예제들에서 x는 클로저의 upvar가 됩니다. upvar는 클로저의 컨텍스트에 바인딩되지 않는다는 의미에서 자유 변수라고도 불립니다. compiler/rustc_passes/src/upvars.rs는 이를 위해 upvars_mentioned라는 쿼리를 정의합니다.
지연 호출 외에도 클로저가 일반 함수와 다른 점이 하나 더 있습니다. 클로저는 upvar를 사용할 수 있습니다. 클로저는 이러한 upvar를 주변 컨텍스트에서 빌려 오므로 컴파일러는 upvar의 빌림 타입을 결정해야 합니다. 컴파일러는 먼저 불변 빌림 타입을 할당한 뒤 사용 방식에 따라 필요한 만큼 제약을 완화합니다(즉, 불변에서 가변으로, 다시 이동으로 바꿉니다). 위의 예제 1에서 클로저는 변수를 출력하는 데에만 사용하고 전혀 수정하지 않습니다. 따라서 mir_dump에서 upvar x의 빌림 타입이 불변임을 확인할 수 있습니다. 그러나 예제 2에서는 클로저가 x를 수정하여 어떤 값만큼 증가시킵니다. 이 변경 때문에 처음에 x를 불변 참조 타입으로 할당했던 컴파일러는 이를 가변 참조로 조정해야 합니다. 마찬가지로 세 번째 예제에서는 클로저가 벡터를 드롭하므로 변수 x가 클로저 안으로 이동되어야 합니다. 클로저는 빌림 종류에 따라 알맞은 트레이트를 구현해야 합니다. 불변 빌림에는 Fn 트레이트를, 가변 빌림에는 FnMut를, 이동 의미론에는 FnOnce를 구현합니다.
클로저와 관련된 코드는 대부분 compiler/rustc_hir_typeck/src/upvar.rs 파일에 있으며, 자료 구조는 compiler/rustc_middle/src/ty/mod.rs 파일에 선언되어 있습니다.
더 나아가기 전에 rustc 코드베이스에서 제어 흐름을 살펴보는 방법을 알아봅시다. 클로저의 경우에는 아래와 같이 RUSTC_LOG 환경 변수를 설정하고 출력을 파일에 모으십시오.
> RUSTC_LOG=rustc_hir_typeck::upvar rustc +stage1 -Z dump-mir=all \
<.rs file to compile> 2> <file where the output will be dumped>
이 명령은 stage1 컴파일러를 사용하며 rustc_hir_typeck::upvar 모듈에 대해 debug! 로깅을 활성화합니다.
다른 방법은 lldb나 gdb를 사용하여 코드를 한 단계씩 실행해 보는 것입니다.
rust-lldb build/host/stage1/bin/rustc test.rs- lldb에서:
b upvar.rs:134// upvar.rs 파일의 특정 줄에 중단점을 설정합니다r// 중단점에 도달할 때까지 프로그램을 실행합니다
upvar.rs부터 시작합시다. 이 파일에는 euv::ExprUseVisitor라는 것이 있습니다. 이것은 클로저의 소스를 순회하면서 빌려지거나 변경되거나 이동되는 upvar마다 콜백을 호출합니다.
fn main() {
let mut x = vec![21];
let _cl = || {
let y = x[0]; // 1.
x[0] += 1; // 2.
};
}
위의 예제에서 방문자는 1과 2로 표시된 줄에 대해 두 번 호출됩니다. 한 번은 공유 빌림에 대해, 다른 한 번은 가변 빌림에 대해 호출됩니다. 방문자는 무엇이 빌려졌는지도 알려 줍니다.
콜백은 Delegate 트레이트를 구현하여 정의합니다. InferBorrowKind 타입은 Delegate를 구현합니다. 또한 각 upvar에 어떤 캡처 모드가 필요했는지를 기록하는 맵을 유지합니다. 캡처 모드는 ByValue(이동됨) 또는 ByRef(빌려짐)일 수 있습니다. ByRef 빌림의 경우 가능한 BorrowKind 값은 compiler/rustc_middle/src/ty/mod.rs에 정의된 대로 ImmBorrow, UniqueImmBorrow, MutBorrow입니다.
Delegate는 몇 가지 메서드(각기 다른 콜백)를 정의합니다. 변수의 이동에는 consume을, 어떤 종류(공유 또는 가변)의 빌림에는 borrow를, 무언가의 대입을 만났을 때는 mutate를 사용합니다.
이 모든 콜백에는 공통 인자 cmt가 있습니다. 이 이름은 Category, Mutability, Type의 약자이며, 이 인자는 compiler/rustc_hir_typeck/src/expr_use_visitor.rs에 정의되어 있습니다. 코드 주석을 인용하면 “cmt는 값의 완전한 분류로서, 값이 어디에서 비롯되었고 어떻게 위치하는지, 그리고 값이 저장된 메모리의 가변성을 나타냅니다”. 콜백(소비, 빌림 등)에 따라 관련 adjust_upvar_borrow_kind_for_<something>를 호출하고 cmt를 함께 전달합니다. 빌림 타입이 조정되면 이를 테이블에 저장합니다. 이 테이블은 기본적으로 각 클로저에서 어떤 빌림이 이루어졌는지를 나타냅니다.
self.tables
.borrow_mut()
.upvar_capture_map
.extend(delegate.adjust_upvar_captures);