클로저가 무엇을 어떻게 캡처하는지 명시할 수 있는 ‘명시적 캡처 절’을 소개하고, 동기와 설계 아이디어, 장단점과 FAQ를 통해 왜 도움이 되는지, 그리고 어디에 한계가 있는지 논의합니다.
이전 글 Ergonomic Ref Counting에서, 무엇을 하든 간에 인체공학적으로(쓰기 편하게) 명시적인 핸들 생성 방법이 필요하다고 말했습니다. 앞으로의 몇 개 글에서는 그것을 어떻게 할 수 있을지 몇 가지 옵션을 탐색하려 합니다.
이번 글은 클로저가 캡처하는 장소 집합을 명시적으로 주석 달 수 있게 하는 명시적 캡처 절(explicit capture clauses)에 초점을 맞춥니다. 제 생각에는 아래에서 말할 이유들 때문에 명시적 캡처 절은 거의 고민할 필요가 없는 당연한 선택이고, 반드시 도입해야 한다고 봅니다. 다만 이것만으로는 ‘인체공학적’이라고 부를 만큼 충분하지 않을 수 있으므로, 이후 글들에서 더 많은 제안을 살펴보겠습니다.
오늘날의 러스트 클로저는 꽤 잘 작동하지만, 몇 가지 문제가 있다고 봅니다:
move가 필요한지에 대한 직관을 갖기 어렵습니다. 저도 컴파일러가 요구할 때마다 붙이곤 하는데, 성가십니다.얼마 전, 명시적 캡처 절에 대한 제안을 썼습니다. 사실 이 제안에는 많은 결함이 있다고 봅니다. 그래도 설명하겠습니다. 지금으로선 제가 아는 유일한 단단한 제안이고, 명시적 캡처 절이 어떻게 보면 “명시적이면서도 인체공학적인” 목표의 해법이 될 수 있는지를 설명하기에는 충분하기 때문입니다. 그 뒤에 제가 이 제안에서 좋아하는 점과 그렇지 않은 점을 다루겠습니다.
이 제안은 move 키워드를 확장하여 캡처할 장소(place)들의 목록을 받도록 합니다:
let closure = move(a.b.c, x.y) || {
do_something(a.b.c.d, x.y)
};
그러면 클로저는 이 두 장소의 소유권을 가져옵니다. 클로저 본문에서 그 장소들에 대한 참조는 캡처된 필드에 대한 접근으로 대체됩니다. 따라서 위 예시는 다음처럼 디슈거링됩니다:
let closure = {
struct MyClosure {
a_b_c: Foo,
x_y: Bar,
}
impl FnOnce<()> for MyClosure {
fn call_once(self) -> Baz {
do_something(self.a_b_c.d, self.x_y)
// ---------- --------
// 장소 `a.b.c`는 |
// 필드 `self.a_b_c`로 |
// 다시 작성됩니다 |
// `x.y`에 대해서도 동일
}
}
MyClosure {
a_b_c: self.a.b.c,
x_y: self.x.y,
}
};
이처럼 단순한 목록을 사용할 때, 캡처되지 않은 다른 장소를 참조하려 하면 오류가 발생합니다:
let closure = move(a.b.c, x.y) || {
do_something(a.b.c.d, x.z)
// ------- ---
// OK 오류: `x.z`는 캡처되지 않음
};
= 기호를 사용하면 사용자 정의 식을 캡처할 수도 있습니다. 예를 들어, 위의 클로저를 다음처럼 다시 쓸 수 있습니다:
let closure = move(
a.b.c = a.b.c.clone(),
x.y,
) || {
do_something(a.b.c.d, x.z)
};
그러면 다음처럼 디슈거링됩니다:
let closure = {
struct MyClosure { /* 위와 동일 */ }
impl FnOnce<()> for MyClosure { /* 위와 동일 */ }
MyClosure {
a_b_c: self.a.b.c.clone(),
// ------------------
x_y: self.x.y,
}
};
이 형태를 사용할 때, a.b.c에 대입되는 식은 주변 스코프에서의 a.b.c와 같은 타입이어야 합니다. 따라서 다음은 오류입니다:
let closure = move(
a.b.c = 22, // 오류: `i32`는 `Foo`가 아님
x.y,
) || {
/* ... */
};
move(a.b)는 move(a.b = a.b)의 설탕(sugar)이라고 이해할 수 있습니다. 다음과 같은 다른 편리한 축약도 지원합니다:
move(a.b.clone()) || {...}
// == 메서드 호출로 끝나면 무엇이든 다음으로 변환 ==>
move(a.b = a.b.clone()) || {...}
그리고 약간 특별한 두 가지 축약이 있습니다:
move(&a.b) || { ... }
move(&mut a.b) || { ... }
이 둘은 특별한데, 캡처된 값이 실제로 &a.b와 &mut a.b이기 때문입니다. 하지만 그것만으로는 타입이 맞지 않아서 작동하지 않습니다. 그래서 a.b에 대한 각 접근을 a_b 필드의 역참조, 즉 *self.a_b로 디슈거링되도록 다시 씁니다:
move(&a.b) || { foo(a.b) }
// 디슈거링 결과
struct MyStruct<'l> {
a_b: &'l Foo
}
impl FnOnce for MyStruct<'_> {
fn call_once(self) {
foo(*self.a_b)
// ---------
// `*`도 우리가 삽입합니다
}
}
MyStruct {
a_b: &a.b,
}
move(&a.b) || { foo(*a.b) }
이런 변환에는 선례가 많습니다. 정확히 Deref 트레이트와 기존 클로저 캡처에서 우리가 하던 것입니다.
새 변수를 정의할 수도 있어야 합니다. 이러한 변수는 타입이 임의적일 수 있습니다. 값은 클로저 생성 시점에 평가되어 클로저 메타데이터에 저장됩니다:
move(
data = load_data(),
y,
) || {
take(&data, y)
}
지금까지의 예시는 캡처된 변수를 완전히 열거했습니다. 하지만 오늘날 러스트의 클로저는 사용된 경로에 기반해 캡처 집합(과 캡처 스타일)을 추론합니다. 이것도 허용해야 합니다. 저는 이를 .. 설탕으로 허용하려 합니다. 따라서 다음 두 클로저는 동등합니다:
let c2 = move || /* closure */;
// ---- 사용되는 것은 무엇이든 캡처하되
// 소유권을 가져감
let c1 = move(..) || /* closure */;
// ---- 사용되는 나머지는 무엇이든 캡처하되
// 소유권을 가져감
물론 결합할 수도 있습니다:
let c = move(x.y.clone(), ..) || {
};
그리고 ref를 써서 || 클로저와 동등한 동작을 얻을 수 있습니다:
let c2 = || /* closure */;
// -- 사용되는 것은 무엇이든 캡처하되,
// 가능하면 참조로 캡처
let c1 = move(ref) || /* closure */;
// --- 사용되는 나머지는 무엇이든 캡처하되,
// 가능하면 참조로 캡처
이렇게 하면 다음이 가능합니다:
let c = move(
a.b.clone(),
c,
ref
) || {
combine(&a.b, &c, &z)
// --- - -
// | | |
// | | 이것은 참조로 캡처됩니다
// | | 참조로 사용되고,
// | | 명시적으로 지정되지 않았기 때문입니다.
// | |
// | 이것은 값으로 캡처됩니다
// | 명시적으로 지정되었기 때문입니다.
// |
// 이것은 사용자가 `a.b.clone()`을 썼기 때문에
// clone을 캡처합니다
}
제가 앞서 든 동기들을 다시 봅시다:
문법이 꽤 많지만, 설명에 사용할 수 있는 명시적 형태를 제공합니다. 무슨 말인지 보려면, 이 두 클로저의 차이를 보세요(playground).
첫 번째 클로저는 ||를 사용합니다:
fn main() {
let mut i = 3;
let mut c_attached = || {
let j = i + 1;
std::mem::replace(&mut i, j)
};
...
}
두 번째 클로저는 move를 사용합니다:
fn main() {
let mut i = 3;
let mut c_detached = move || {
let j = i + 1;
std::mem::replace(&mut i, j)
};
이 둘은 실제로 꽤 다릅니다. 왜 그런지는 이 플레이그라운드에서 볼 수 있습니다. 그런데 왜일까요? 첫 번째 클로저는 참조를 캡처하도록 디슈거링되기 때문입니다:
let mut i = 3;
let mut c_attached = move(&i) || {...};
그리고 두 번째 것은 값으로 캡처합니다:
let mut i = 3;
let mut c_attached = move(i) || {...};
예전에는 이것을 설명하려면 구조체로의 디슈거링으로 돌아가야 했습니다.
오늘날 어떤 클로저가 무언가의 clone을 캡처하고자 한다면, 새 변수를 도입해야 합니다. 다음과 같은 코드는:
let closure = move || {
begin_actor(data, self.tx.clone())
};
다음처럼 바뀝니다:
let closure = {
let self_tx = self.tx.clone();
move || {
begin_actor(data, self_tx.clone())
}
};
성가십니다. 이 제안에서는 특정 항목을 점 단위로 교체할 수 있습니다:
let closure = move(self.tx.clone(), ..) || {
begin_actor(data, self.tx.clone())
};
빨리! 이 클로저는 환경에서 어떤 변수를 사용하나요?
.flat_map(move |(severity, lints)| {
parse_tt_as_comma_sep_paths(lints, edition)
.into_iter()
.flat_map(move |lints| {
// `::`로 식별자를 다시 이어 공백을 없앱니다.
lints.into_iter().map(move |lint| {
(
lint.segments().filter_map(
|segment| segment.name_ref()
).join("::").into(),
severity,
)
})
})
})
모르겠죠? 저도요. 이건 어떤가요?
.flat_map(move(edition) |(severity, lints)| {
/* 위와 동일 */
})
아, 꽤 명확하네요! 저는 클로저가 두어 줄을 넘어가면, 함수가 다소 읽기 어려워진다고 느낍니다. 주변의 어떤 변수에 접근할 수 있는지 파악하기가 어렵기 때문입니다. 어떤 이유로든 특정 클로저가 주변 값의 일부만 접근해야 하는 것이 정확성에 중요했던 함수들도 있었는데, 지금은 이를 표시할 방법이 없습니다. 때로는 별도의 함수를 만들기도 하지만, 클로저의 캡처를 명시적으로 주석 달 수 있다면 더 좋을 것입니다.
move가 필요한지에 대한 직관을 갖기 어렵다흠, 사실 이 표기는 그 문제에는 전혀 도움이 되지 않는 것 같습니다! 아래에서 더 이야기하겠습니다.
이 설계에 대해 궁금할 수 있는 몇 가지를 짚어보죠.
a.b.c처럼 전체 장소(place)를 지정할 수 있게 하나요?오늘날에도 다음처럼 self.context 같은 장소를 캡처하는 클로저를 쓸 수 있습니다:
let closure = move || {
send_data(self.context, self.other_field)
};
제 목표는, 이런 클로저를 그대로 두고 본문을 깊게 고치지 않으면서, 특정 장소가 어떻게 캡처되는지를 바꾸는 주석을 덧붙일 수 있게 하는 것입니다:
let closure = move(self.context.clone(), ..) || {
// --------------------------
// 오직 이 부분만 변경
send_data(self.context, self.other_field)
};
이는 확실히 복잡성을 더합니다. a.b.c처럼 여러 부분으로 이루어진 장소를 “재매핑(remap)”할 수 있어야 하기 때문입니다. 하지만 이것은 명시적 캡처 문법을 훨씬 더 강력하고 편리하게 만듭니다.
a.b.c 같은 장소의 타입을 동일하게 유지하나요?a.b.c가 어디에서 타입 검사되든 동일한 타입이 되게 하고 싶습니다. 그러면 컴파일러가 다소 단순해지고, 코드를 클로저 안팎으로 옮기기도 전반적으로 쉬워집니다.
거기 있으니까요? 솔직히 move의 선택은 너무 ‘작동 방식 중심’이라 마음에 들지 않습니다. 시간이 돌아간다면, 저는 우리의 클로저를 두 개념으로 다시 구성하려 할 것 같습니다.
||)는 항상 둘러싼 스택 프레임에 묶입니다. 아무것도 캡처하지 않더라도 언제나 수명(lifetime)이 있습니다.move ||)는 오늘날의 move처럼 값으로 캡처합니다.이렇게 하면 “현재 스택 프레임에서 클로저를 반환하려면 detach ||를, 그렇지 않으면 ||를 쓰라”는 직관을 쌓는 데 도움이 될 것입니다.
극도로 최소한의 명시적 캡처 제안은 아마 특정 변수들만 이름으로 지정하고, ‘하위 장소(subplace)’는 허용하지 않는 형태일 것입니다:
move(
a_b_c = a.b.c,
x_y = &x.y
) || {
*x_y + a_b_c
}
하지만 이렇게 되면 명시적 형태를 도입하는 것이 훨씬 덜 즐겁게 되며, 따라서 인체공학적인 RC를 지원하는 데 별 도움이 되지 않을 것입니다.
명시적 캡처 절을 도입하는 것은 좋은 생각이라고 봅니다. 저는 일반적으로 러스트의 모든 것에 대해, 교육과 설명의 목적에서라도 명시적 문법이 있어야 한다고 생각합니다. 예전엔 이렇게까지 생각하지 않았지만, 시간이 지나며 그 가치를 알게 되었습니다.
이 구체적인 제안에 완전히 마음이 팔린 것은 아닙니다. 하지만 이를 통해 (a) 어떤 이점이 있는지, (b) 얼마나 많은 숨은 복잡성이 있는지를 파악하는 데는 유익하다고 봅니다.
이 제안은 명시적 캡처 절을 추가하면 ‘명시적이면서도 인체공학적인’ 방향으로 어느 정도 나아갈 수 있음을 보여줍니다. move(a.b.c.clone())를 쓰는 것이 새 바인딩을 만들어야 하는 것보다 확실히 좋습니다.
하지만 제게는 아직 충분히 ‘기분 좋게’ 느껴지진 않습니다. 클로저의 시작 지점을 찾아 a.b.c.clone() 호출을 삽입해야 하고, 클로저 헤더가 아주 길고 투박해지기 때문입니다. 특히 짧은 클로저에서는 오버헤드가 매우 큽니다.
이 때문에 저는 다른 옵션들도 살펴보려 합니다. 그럼에도 불구하고, 명시적 형태에 대한 제안을 논의해 둔 것은 유용합니다. 최소한 나중에 다른 제안들의 정확한 의미론을 설명하는 데 도움이 될 테니까요.