참조 카운팅의 사용성을 높이기 위한 또 다른 제안인 ‘move 표현식’을 소개한다. 이는 명시적 캡처 절의 대안으로, 기존 설계의 많은 목표를 더 나은 인체공학과 가독성으로 달성한다.
이 글은 내가 ‘move 표현식’이라고 부르는, 참조 카운팅 사용성(ergonomics) 영역의 또 다른 제안을 탐구한다. 내 생각에 이는 명시적 캡처 절(clause)의 대안으로, 그 설계가 가진 목표 중 많은 부분을(모두는 아니더라도) 더 나은 인체공학과 가독성으로 달성한다.
아이디어 자체는 단순하다. 클로저(또는 future) 내부에서 move($expr)를 쓸 수 있게 하자는 것이다. 이는 클로저로 옮겨 넣는 임시 값을 만들어 내는 값 표현식(“rvalue”)이다. 따라서
|| something(&move($expr))
은 대략 다음과 같이 디슈가링된다:
{
let tmp = $expr;
|| something(&{tmp})
}
우리의 계속된 예시 중 하나인 “Cloudflare 예시”로 돌아가 보자. 이 예시는 Dioxus 팀의 훌륭한 블로그 글에서 비롯되었다. 상기 차원에서, 오늘날 코드는 다음과 같이 보인다 — 캡처를 다루기 위한 let _some_value = ... 줄에 주목하라:
// task: listen for dns connections
let _some_a = self.some_a.clone();
let _some_b = self.some_b.clone();
let _some_c = self.some_c.clone();
tokio::task::spawn(async move {
do_something_else_with(_some_a, _some_b, _some_c)
});
이 제안 아래에서는 다음과 비슷하게 보일 것이다:
tokio::task::spawn(async {
do_something_else_with(
move(self.some_a.clone()),
move(self.some_b.clone()),
move(self.some_c.clone()),
)
});
여러 번 clone이 필요한 경우도 있다. 예를 들어, 무언가를 FnMut 클로저로 옮겨 넣고, 그 클로저가 매 호출마다 복사본을 내주게 하고 싶다면, 다음처럼 보일 수 있다:
data_source_iter
.inspect(|item| {
inspect_item(item, move(tx.clone()).clone())
// ---------- -------
// | |
// 클론을 이동 |
// 하여 클로저에 넣고 |
// |
// 각 반복에서
// 그 클론을 다시 clone
})
.collect();
// 이후에 어딘가에서 `tx`를 계속 사용...
이 아이디어는 내 것이 아니다. 여러 번 제안된 바 있다. 내가 처음 들은 것은 RustConf Unconf였던 것으로 기억하지만, 그 이전에도 나왔던 것 같다. 가장 최근에는 Zachary Harrold가 Zulip에서 제안했으며, 그는 soupa라는 프로토타입도 만들었다. Zachary의 제안은, 내가 전에 들었던 다른 제안들처럼, super 키워드를 사용했다. 그 뒤 @simulacrum이 move를 쓰자고 제안했는데, 이는 내게 큰 개선처럼 느껴졌고, 여기서는 그 버전을 채택했다.
내가 move 변형을 좋아하는 이유는 클로저를 더 “연속적(continuous)”으로 만들고 그 기저 모델을 더 분명히 드러내기 때문이다. 이 설계라면, 나는 클로저를 설명할 때 move 표현식부터 시작하고, 마지막에 move 클로저를 편의상 기본값으로 가르칠 것이다:
러스트의 클로저는 당신이 사용하는 장소들을 “가능한 최소한의 방식”으로 캡처한다 — 따라서
|| vec.len()은vec에 대한 공유 참조를 캡처하고,|| vec.push(22)는 가변 참조를 캡처하며,|| drop(vec)은 벡터의 소유권을 가져간다.
move표현식을 사용하여 정확히 무엇을 캡처할지 제어할 수 있다: 예를 들어|| move(vec).push(22)는 그vector를 클로저로 이동시킨다. 완전히 명시적으로 하고 싶을 때 흔한 패턴은 클로저의 맨 위에 모든 캡처를 나열하는 것이다. 예를 들면 다음과 같다:|| { let vec = move(input.vec); // vec의 완전한 소유권을 가져오기 let data = move(&cx.data); // data에 대한 참조를 캡처하기 let output_tx = move(output_tx); // 출력 채널의 소유권을 가져오기 process(&vec, &mut output_tx, data) }축약형으로, 클로저의 시작에
move ||를 쓸 수 있으며, 그러면 클로저가 캡처하는 모든 변수를 기본적으로 소유권 이동하여 캡처하도록 변경된다. 더 세밀한 제어가 필요하면 여전히move표현식과 혼용할 수 있다. > 따라서 앞선 클로저는 더 간결하게 다음처럼 쓸 수 있다:move || { process(&input.vec, &mut output_tx, move(&cx.data)) // --------- --------- -------- // | | | // | | 클로저는 여전히 // | | &cx.data를 참조로 캡처 // | | // 클로저에 `move` 키워드가 있으므로, // 이 둘은 "move로" 캡처됨 // }
move를 나에게 “자연스럽게” 만든다조금은 아이러니하다. 이는 내가 최근에 불평했던 러스트 설계의 한 부분을 더 밀어붙이는 것이기 때문이다. 명시적 캡처 절에 관한 이전 글에서 나는 이렇게 썼다:
솔직히 말해,
move라는 선택은 너무 _작동 방식적(operational)_이라 마음에 들지 않는다. 시간을 되돌릴 수 있다면, 우리의 클로저를 두 가지 개념으로 재구성하려고 했을 것이다.
부착된(attached) 클로저(지금의
||)는 항상 둘러싼 스택 프레임에 묶인다. 캡처가 하나도 없더라도 항상 수명을 가진다.분리된(detached) 클로저(지금의
move ||)는 오늘날의move처럼 값으로 캡처한다.이렇게 하면 “현재 스택 프레임으로부터 클로저를 반환하려면
detach ||를, 그 외에는||를 쓰라”는 직관을 세우는 데 도움이 되었을 것이다.
move 표현식은, 내 생각엔, 그 반대 방향으로 나아간다. 부착/분리를 말하기보다는 더 통합된 클로저 개념으로 이끈다. 즉 “참조 클로저”와 “move 클로저”가 따로 있는 게 아니라, 때로는 move를 캡처하는 하나의 클로저만 있을 뿐이며, “move 클로저”는 단지 어디서든 move 표현식을 쓰는 것의 축약형일 뿐이다. 사실 이것이 컴파일러 내부에서의 클로저 동작 방식이며, 꽤 우아하다고 생각한다.
질문 하나는 move 표현식을 _접두사(prefix)_로 둘지 접미사(postfix) 연산자로 둘지다. 예를 들어
|| something(&$expr.move)
처럼, &move($expr) 대신에 말이다.
내 느낌에는 접미사 연산자와는 잘 맞지 않는다. 왜냐하면 단지 최종 값에 어떤 일을 하는 것이 아니라, 표현식 전체가 평가되는 시점에 영향을 미치기 때문이다. 이 예시를 보자:
|| process(foo(bar()).move)
bar()는 언제 호출되는가? 잘 생각해 보면, 클로저 생성 시점이어야만 하지만, 그렇게 “자명”하지 않다.
우리는 .unsafe 연산자를 고려했을 때도 비슷한 결론에 도달했다. 코드의 “범위(scope)”를 구분 짓는 것들은 접두사여야 한다는 경험칙이 있다고 생각한다 — 다만 unsafe { expr } 대신 unsafe(expr)가 실제로 괜찮을 수도 있다고는 본다.
추가: 질문에 대한 응답으로 사후에 이 섹션을 덧붙였다.
여기서 글을 마무리하겠다. 솔직히 말해, 이 설계의 가장 큰 장점은 _단순함_과 러스트의 _기존 설계를 일반화_한다는 점이다. 나는 이런 점이 마음에 든다. 내게는 다음과 같은, “그래, 이건 분명히 해야지” 싶은 퍼즐 조각들에 속한다:
Share 트레이트를 추가한다(다시 share라는 이름을 선호하게 됐다 😁)move 표현식을 추가한다이 둘은 모두 확실한 전진이라고 본다. 다만 이전 글에서 내가 제시했던 목표에 완전히 도달했는지에 대해서는 아직 확신하지 못하겠다:
“커널에도 충분히 저수준이면서, GUI에도 충분히 사용하기 쉬운”
하지만 올바른 방향으로 나아가고 있다.