클로저 캡처를 더 편리하게 만드는 방법을 논의하고, C++의 캡처 문법을 비교해 러스트에 적용 가능한 캡처 절 아이디어를 요구사항과 예시로 탐색한다. self 처리, 와일드카드, 복제(+), 참조(&), 이동(=) 기반 캡처 등을 다룬다.
클로저 캡처를 더 인체공학적으로(덜 번거롭게) 만드는 방법에 대해 여러 논의가 있었다. 동기와 현재 나아갈 방향에 대한 배경은 이 블로그 글을 읽어보길 권한다.
여러 Reddit 토론에서 명시적 캡처(explicit capture)라는 아이디어가 나왔고, 그 일부를 다루는 블로그 글이 여기에 게시되었다.
이 글에서는 C++이 같은 문제를 어떻게 처리하는지 살펴보고, 러스트에서도 비슷한 방식으로 풀 수 있을지 탐색해 보려 한다.
러스트의 클로저는 사실상 전부 아니면 전무다. 참조로 전부 캡처하거나, move로 전부 캡처한다. 개별 값에만 접근 권한을 부여하는 방법은 없어서, 실질적으로는 바깥 스코프의 모든 것에 접근 가능해진다.
일부는 참조로, 일부는 move로 캡처하고 싶다면 그 준비를 클로저 바깥에서 해야 한다. 클로저 안으로 값을 복제하여 넣고 싶다면 먼저 clone한 뒤 그 복제본을 move로 넘기는 수밖에 없다.
let some_value = Arc::new(something);
// 작업 1
let _some_value = some_value.clone();
tokio::task::spawn(async move {
do_something_with(_some_value);
});
// 작업 2
let _some_value = some_value.clone();
tokio::task::spawn(async move {
do_something_else_with(_some_value);
});
클로저 안으로 복제해서 넘겨야 하는 값이 많아지면, 사용성이 매우 떨어진다.
let _some_a = self.some_a.clone();
let _some_b = self.some_b.clone();
let _some_c = self.some_c.clone();
let _some_d = self.some_d.clone();
let _some_e = self.some_e.clone();
let _some_f = self.some_f.clone();
let _some_g = self.some_g.clone();
let _some_h = self.some_h.clone();
let _some_i = self.some_i.clone();
let _some_j = self.some_j.clone();
tokio::task::spawn(async move {
// 위 값들 전부를 사용
});
그리고 클로저가 어떤 바깥 값을 사용할 수 있는지 제어할 수 없기 때문에, 클로저 안에서 실수로 엉뚱한 값을 참조하기도 쉽다.
이 문제들을 바탕으로 다음 요구사항을 충족해야 한다:
또 다음 요구사항도 목록에 포함되어야 한다고 생각한다:
마지막 요구는 컨텍스트가 중요하기 때문이다. 어떤 부분에서는 Vec이나 String 같은 큰 자료구조를 복제해도 괜찮을 수 있다. 다른 부분에서는 절대로 그러면 안 된다.
공교롭게도, 이 영역은 C++이 정확히 잘한 부분이라고 생각한다.
여러 시나리오를 보며 C++과 러스트를 비교해 보자.
| 러스트 | C++ | C++ 대안 |
|---|---|---|
| let mul_2 = | x | x*2; |
| let mut y = 3i32; let mut add_one = | y += 1; | |
| let mut y = 3i32; let mut add_one = move | y += 1; | |
| let some_arc = Arc::new(something); let _some_arc = some_arc.clone(); let do_something = move | do_something_impl(_some_arc); |
C++는 세밀한 제어와 축약 표기 모두를 제공한다. 이 경우 사실상 두 세계의 장점을 다 취한다.
우리의 요구사항 관점에서는 두 항목을 모두 충족한다… 거의.
C++에서는 this 포인터 때문에 상황이 더 어색해진다.
#include <cstdio>
struct Data {
int x = 5;
auto get_closure() {
return [=]() {
x += 1;
};
}
};
int main() {
Data d;
auto clos = d.get_closure();
std::printf("%d\n", d.x); // 5 출력
clos();
std::printf("%d\n", d.x); // 6 출력
}
위 예시에서 암시적인 this 포인터는 값으로 캡처되지만, 포인터이기 때문에 실제로 x는 참조로 접근된다. 그래서 [=]로 모든 것을 값으로 캡처했음에도, clos() 안에서 d.x가 수정되는 것이다.
현재 C++에서는 [=] 캡처 형식에서 this의 암시적 캡처가 폐지(deprecated)되었지만, 여전히 this는 다소 이상한 존재다.
이를 해결하려면 C++에서는 다음과 같이 해야 한다:
#include <cstdio>
struct Data {
int x = 5;
auto get_closure() {
// this->x를 캡처해서 클로저 내부 지역 변수 x에 대입한다.
return [x=x] mutable {
x += 1; // 클로저 내부의 지역 변수 x만 수정된다.
};
}
};
int main() {
Data d;
auto clos = d.get_closure();
std::printf("%d\n", d.x); // 5 출력
clos();
std::printf("%d\n", d.x); // 5 출력
}
그렇다면 C++의 접근을 러스트에도 적용할 수 있을까? 가능성을 살펴보자.
다음은 바깥 스코프의 어떤 변수에도 접근할 수 없는 빈 캡처 목록을 가진 클로저다. 바깥 변수를 참조하려 하면 컴파일 에러가 난다:
let x = || 5;
// 이렇게 바뀔 수 있다
let x = []|| 5;
여기서는 캡처를 명시적으로 제어하거나, 참조로 필요한 것만 캡처하도록 할 수 있다:
let my_vec = Vec::new();
let x = || my_vec.len();
// 이렇게 바뀔 수 있다
let x = [&my_vec]|| my_vec.len();
// 대안
let x = [&]|| my_vec.len();
마찬가지로 move 캡처에도 적용할 수 있다:
let my_vec = Vec::new();
let x = move || my_vec.len();
// 이렇게 바뀔 수 있다
let x = [my_vec]|| my_vec.len();
// 대안
let x = [=]|| my_vec.len();
clone도 동일하게:
let my_vec = Vec::new();
let _my_vec = my_vec.clone();
let x = move || _my_vec.len();
// 이렇게 바뀔 수 있다
let x = [+my_vec]|| my_vec.len();
// 대안
let x = [+]|| my_vec.len();
혼합해서도 가능하다:
let my_vec = Vec::new();
let my_string = String::new();
let my_arc = Arc::new(whatever);
let my_ref = &some_var;
// 기존 방식
let _my_string = &my_string;
let _my_arc = my_arc.clone();
let x = move || something(my_vec, _my_string, _my_arc, some_var);
// 이렇게 바뀔 수 있다
let x = [my_vec, &my_string, +my_arc, some_var]|| something(my_vec, my_string, my_arc, some_var);
C++이 this로 겪는 문제를, 러스트는 self로 겪게 된다.
다음과 같은 구조체를 보자:
struct Data {
some_a: Arc<A>,
some_b: Arc<B>,
some_c: Arc<C>,
}
self.some_a를 참조하려면 먼저 self를 어떻게 캡처할지, 그리고 이어서 some_a를 어떻게 캡처할지 결정해야 한다.
현재로서는 캡처 절에 와일드카드를 허용하는 쪽으로 제안을 하고 싶다:
fn some_f(&self) {
// &self 참조를 캡처한다
do_something(|| self.some_a.something());
do_something([self]|| self.some_a.something());
// 축약 표기로 self를 캡처하는 것은 허용하지 않는다
// 단일 값을 복제해 캡처 허용
do_something({
let some_a = self.some_a.clone();
move || some_a.something()
});
do_something([some_a=self.some_a.clone()]|| some_a.something());
// 약간의 축약 허용:
do_something([+self.some_a]|| self.some_a.something());
// 와일드카드 허용:
do_something([+self.*]|| self.some_a.something());
}
처음에 다음을 해결하려 했다:
2번은 확실히 충족된다. 클로저가 어떤 값을 사용할 수 있는지 명확히 제한할 수 있고, 그것을 어떻게 캡처할지도 제어할 수 있다.
문제 장에서의 두 예시가, 이처럼 더 세밀한 캡처 제어로 어떻게 바뀌는지 보자:
let some_value = Arc::new(something);
// 작업 1
tokio::task::spawn(async [+some_value]|| {
do_something_with(some_value);
});
// 작업 2
tokio::task::spawn(async [+]|| {
do_something_else_with(some_value);
});
두 번째 예시는 변화가 더 극적이니, 먼저 원래 코드를 보자:
let _some_a = self.some_a.clone();
let _some_b = self.some_b.clone();
let _some_c = self.some_c.clone();
let _some_d = self.some_d.clone();
let _some_e = self.some_e.clone();
let _some_f = self.some_f.clone();
let _some_g = self.some_g.clone();
let _some_h = self.some_h.clone();
let _some_i = self.some_i.clone();
let _some_j = self.some_j.clone();
tokio::task::spawn(async move {
// 위 값들 전부를 사용
});
이것은 다음처럼 줄일 수 있다:
tokio::task::spawn(async [+self.*] {
// 위 값들 전부를 사용
});
솔직히 이보다 더 단순하게 만들기 어려울 것 같다. 따라서 요구사항 1도 충족된다.
물론 이 모든 것은 문법 바익셰딩에 가깝지만, 앞으로 나아갈 아이디어와 방향을 제시해 줄 수 있다고 본다.