클로저/미래에서 clone/alias를 인지하는 디슈가링과, 불필요한 호출을 move로 바꾸는 ‘마지막 사용 변환’을 통해, “이동된 값” 오류의 일관된 해법으로서 clone/alias를 제시하고 사용성과 성능을 함께 노리는 제안을 설명합니다.
참조 카운팅의 사용성을 다루는 연재를 이어가며, 이번에는 내가 “그냥 clone(또는 alias)을 호출하라”라고 부르는 아이디어를 탐구해 보려 한다. 이 제안은 clone과 alias 메서드를 특수화하여, 새로운 에디션에서 컴파일러가 (1) 중복되거나 불필요한 호출을 제거하고(린트 포함); (2) 필요한 경우 move 클로저에서 clone/alias를 자동으로 캡처하도록 한다.
이 제안의 목표는 사용자의 정신 모델을 단순화하는 것이다. “이동된 값 사용(use of moved value)” 같은 오류를 볼 때, 해결책은 항상 동일하다: 그냥 clone(해당된다면 alias)을 호출하면 된다. 이 모델은 앞서 설명한 “커널엔 충분히 저수준, GUI엔 충분히 사용 가능”의 균형을 지향한다. 동시에 하나의 메시지도 담고 있다. 우리가 보장하고 싶은 핵심 속성은 “새로운 alias가 어디에서 생성될 수 있는지 언제나 찾을 수 있다”는 점이다. 다만 alias가 “정확히 언제” 생성되는지에 대한 미세한 부분은 다소 미묘해져도 괜찮다는 것이다.
다음과 같은 move future를 보자:
fn spawn_services(cx: &Context) {
tokio::task::spawn(async move {
// ---- move future
manage_io(cx.io_system.alias(), cx.request_name.clone());
// -------------------- -----------------------
});
...
}
이것은 move future이므로 cx.io_system과 cx_request_name의 소유권을 가져간다. cx는 빌린 참조이므로, 해당 값들이 Copy가 아닌 이상(아마도 아닐 것이다) 이는 오류가 된다. 이 제안하에서는 move 클로저/미래에서 alias 또는 _clone_을 캡처하면 그 장소(place)의 alias 또는 _clone_을 캡처하도록 동작한다. 따라서 이 future는 다음처럼 디슈가링된다( 명시적 캡처 절(clause) 의사 표기 사용):
fn spawn_services(cx: &Context) {
tokio::task::spawn(
async move(cx.io_system.alias(), cx.request_name.clone()) {
// -------------------- -----------------------
// 각각 alias/clone으로 캡처
manage_io(cx.io_system.alias(), cx.request_name.clone());
}
);
...
}
그런데 이 결과는 비효율적이다. alias/clone이 이제 두 번 일어난다. 그래서 다음 단계로, 컴파일러가 새로운 Rust 에디션에서 **마지막 사용 변환(last-use transformation)**이라는 변환을 적용한다. 이 변환은 빌림 검사기를 만족시키기 위해 필요하지 않은 alias 또는 clone 호출을 찾아 제거한다. 따라서 코드는 다음처럼 바뀐다:
fn spawn_services(cx: &Context) {
tokio::task::spawn(
async move(cx.io_system.alias(), cx.request_name.clone()) {
manage_io(cx.io_system, cx.request_name);
// ------------ ---------------
// move로 변환됨
}
);
...
}
마지막 사용 변환은 클로저를 넘어선 곳에도 적용된다. 예를 들어, 아래처럼 id를 이후에 전혀 사용하지 않는데도 clone하는 경우를 보자:
fn send_process_identifier_request(id: String) {
let request = Request::ProcessIdentifier(id.clone());
// ----------
// 불필요함
send_request(request)
}
사용자는 다음과 같은 경고를 보게 된다1:
warning: unnecessary `clone` call will be converted to a move
--> src/main.rs:7:40
|
8 | let request = Request::ProcessIdentifier(id.clone());
| ^^^^^^^^^^ unnecessary call to `clone`
|
= help: the compiler automatically removes calls to `clone` and `alias` when not
required to satisfy the borrow checker
help: change `id.clone()` to `id` for greater clarity
|
8 - let request = Request::ProcessIdentifier(id.clone());
8 + let request = Request::ProcessIdentifier(id);
|
그리고 코드는 단순 move를 하도록 변환된다:
fn send_process_identifier_request(id: String) {
let request = Request::ProcessIdentifier(id);
// --
// 변환됨
send_request(request)
}
이 제안의 목표는, “이동된 값 사용”이나 빌린 내용을 이동하려고 한다는 오류가 날 때, 해결책이 항상 동일하도록 하는 것이다. 그냥 clone(또는 alias)을 호출하면 된다. 그 오류가 일반 함수 본문이든, 클로저든, future 안이든 상관없다. 컴파일러가 이후 동일한 장소를 사용할 수 있도록 필요한 clone/alias를 삽입할 것이다(그리고 그 이상은 아니다).
이 접근은 신규 사용자에게 도움이 된다고 믿는다. 러스트를 처음 배울 때 많은 사람들이 견고한 정신 모델을 쌓는 과정에서 clone 호출이나 & 같은 기호들을 다소 무작위로 뿌리곤 한다 — “keep calm and call clone” 농담이 나온 배경이기도 하다. 오늘날 이 접근은 클로저와 future에서 무너진다. 이 제안 아래에서는 동작할 뿐 아니라, 불필요한 clone을 알려주는 경고도 제공되어, clone이 정말 필요한 지점을 이해하는 데 도움이 될 것이다.
하지만 진짜 질문은 이것이 _숙련 사용자_에게 어떻게 작동하느냐다. 이 점에 대해 많이 고민했다! 이 접근은 Bjarne Stroustrup의 고전적인 제로 코스트 추상화 정의에 상당히 부합한다고 생각한다:
“당신이 사용하지 않는 것에는 비용을 지불하지 않는다. 더 나아가: 당신이 사용하는 것이라면, 직접 손으로 더 잘 짤 수 없다.”
첫 번째 절반은 분명히 충족된다. clone이나 alias를 호출하지 않으면, 이 제안은 당신의 삶에 아무 영향도 없다.
핵심은 두 번째 절반이다. 이 제안의 초기 버전은 더 단순했고, 그 결과 중복되거나 불필요한 clone과 alias가 생기기도 했다. 곰곰이 생각해 보니, 이는 출발조차 할 수 없는 조건이었다. 이 제안이 작동하려면 숙련 사용자들이 더 노골적인 형태를 쓴다고 해서 성능상 이점이 없다는 것을 알아야 한다. 이것이 바로 우리가 (예를 들어) 이터레이터에서 이미 갖고 있는 특성이며, 아주 잘 작동한다. 나는 이 제안이 그 기준을 충족한다고 믿지만, 내가 간과한 점이 있는지 의견을 듣고 싶다.
대부분의 사용자는 message.clone()을 그냥 message로 바꿔도, 컴파일이 계속된다면 문제가 없다고 예상할 것이다. 하지만 실제로는 그렇게 되어야만 한다고 강제하는 규칙은 없다. 이 제안 아래에서는, 비정상적인 방식으로 clone의 의미를 크게 만드는 API는 새로운 Rust 에디션에서 사용하기 더 성가셔질 것이고, 결국 “의미 있는 clone”에는 다른 이름을 붙이도록 바뀔 것이라 예상한다. 이는 좋은 일이라고 생각한다.
핵심 포인트는 다뤘다고 본다. 여기서는 FAQ 형식으로 몇 가지 세부사항을 파고들겠다.
그럴 수 있다고 본다. 이참에 내가 보는 동기를 요약해 보자.
우리의 목표는 먼저 “커널엔 충분히 저수준, GUI엔 충분히 사용 가능”한 설계를 지향해야 한다고 믿는다.
Rust의 현재 Clone 접근은 두 사용자 집단 모두에게 실패한다.
clone 호출이 충분히 명시적이지 않다. something.clone()을 보아도, 그게 새로운 alias를 만드는지 완전히 다른 값을 만드는지 알 수 없고, 런타임 비용이 어떨지도 짐작하기 어렵다. 커뮤니티의 많은 이들이 Arc::clone(&something)처럼 쓰길 권하는 이유다.clone 호출은 큰 사용성 골칫거리이며, 이 문제를 처음 논의했을 때부터 명확한 공감대가 있었다.이 문제를 해결하기 위해 나는 세 가지 변화를 제안했고, 각각을 별도 글로 다뤘다:
Alias 트레이트(처음엔 Handle로 명명)를 도입한다. Alias 트레이트는 clone과 동등하지만 동일한 기저 값을 위한 두 번째 alias를 만든다는 점을 나타내는 새 메서드 alias를 도입한다.as_ref나 to_string 결과 캡처)도 지원한다.Dioxus 팀의 훌륭한 글에서 시작된 “Cloudflare 예시”를 통해 각 변화의 영향을 살펴보자:
let some_value = Arc::new(something);
// task 1
let _some_value = some_value.clone();
tokio::task::spawn(async move {
do_something_with(_some_value);
});
// task 2: 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)
});
원문이 이렇게 말했다:
이 코드베이스에서 일하는 건 의욕을 꺾었다. 더 나은 구조를 생각할 수 없었다 — 앱 상태를 기준으로 업데이트를 필터링하는 리스너가 사실상 모든 것에 필요했다. “실력 키워라(lol get gud)”라고 할 수도 있겠지만, 이 팀의 엔지니어들은 내가 일했던 사람들 중 가장 날카로웠다. Cloudflare는 Rust에 올인했다. 이런 코드베이스에 돈을 아끼지 않는다. 만약 상태 공유가 이렇게 동작한다면, 핵융합은 Rust로 풀리지 않을 것이다.
Alias 트레이트와 명시적 캡처 절을 적용하면 약간 나아진다. 이제 clone 호출이 alias 호출임을 명확히 볼 수 있고, 어색한 _some_value, _some_a 같은 변수가 없다. 하지만 여전히 다소 장황하다:
let some_value = Arc::new(something);
// task 1
tokio::task::spawn(async move(some_value.alias()) {
do_something_with(some_value);
});
// task 2: listen for dns connections
tokio::task::spawn(async move(
self.some_a.alias(),
self.some_b.alias(),
self.some_c.alias(),
) {
do_something_else_with(self.some_a, self.some_b, self.some_c)
});
Just Call Clone 제안을 적용하면 상용구가 크게 줄어들고, 코드의 _의도_를 매우 잘 드러낸다고 생각한다. 또한 alias 호출을 검색하면 alias가 생성될 모든 위치를 찾아볼 수 있다는 점에서 상당한 수준의 명시성도 유지된다. 다만 약간의 미묘함은 생긴다. 예컨대 self.some_a.alias() 호출은 실제로 future가 _await_될 때가 아니라 future가 생성될 때 발생한다는 점이다:
let some_value = Arc::new(something);
// task 1
tokio::task::spawn(async move {
do_something_with(some_value.alias());
});
// task 2: listen for dns connections
tokio::task::spawn(async move {
do_something_else_with(
self.some_a.alias(),
self.some_b.alias(),
self.some_c.alias(),
)
});
Just Call Clone이 클로저/future 디슈가링을 더 미묘하게 만드는 것은 사실이다. task 1을 보자:
tokio::task::spawn(async move {
do_something_with(some_value.alias());
});
이는 future가 _await_될 때가 아니라 future가 생성될 때 alias를 호출하도록 디슈가링된다. 명시적 형태로 쓰면 다음과 같다:
tokio::task::spawn(async move(some_value.alias()) {
do_something_with(some_value)
});
처음에는 사람들이 헷갈릴 수 있다고 본다 — “alias 호출이 마치 future(또는 클로저) 내부에 있는 것처럼 보이는데, 왜 더 일찍 발생하지?”
하지만, 이 코드는 정말로 가장 중요한 것을 보존한다. 즉, 코드베이스에서 alias 호출을 검색하면, 이 태스크를 위해 alias가 생성된다는 사실을 찾을 수 있다. 그리고 대부분의 현실 사례에서, alias가 “태스크가 생성될 때” 만들어지는지, “실행될 때” 만들어지는지는 중요하지 않다. 이 코드를 보라. 중요한 것은 do_something_with가 some_value의 alias로 호출된다는 점이다. 따라서 do_something_else가 실행되는 동안 some_value는 살아 있다. “파이프가 어떻게 연결되었는지”는 크게 중요하지 않다.
좋은 지적이다. 그런 예시는 헷갈릴 여지가 더 많다. 예를 들어:
tokio::task::spawn(async move {
if false {
do_something_with(some_value.alias());
}
});
이 예시에서는 some_value를 alias로 사용하는 코드가 있긴 하지만, if false 안에서만 그렇다. 그럼 어떻게 될까? 나는 이 future가 실제로 some_value의 alias를 _캡처_할 것이라고 가정한다. 이는 아래 future가 해당 코드가 죽어 있더라도 some_value를 _move_하는 것과 같은 맥락이다:
tokio::task::spawn(async move {
if false {
do_something_with(some_value);
}
});
그렇다! 다음과 같이 생각하고 있다:
move가 아닌 클로저/future는 변경 없음. 따라서
move 클로저/future의 경우 다음과 같이 변경한다
각 장소 P의 사용을 분류하고 해당 장소를 어떻게 캡처할지 결정한다…
P.clone() 또는 P.alias() 호출이 최소 하나 있고, 그 외의 모든 P 사용이 공유 참조(읽기)만 필요할 때P.clone()이나 P.alias() 호출이 없거나, 소유권 또는 가변 참조가 필요한 사용이 있을 때장소 a.b.c가 공유 참조로만 사용되고, 그 중 하나 이상이 clone 또는 alias인 경우에는 clone/alias로 캡처한다.
a나 “접미 장소” a.b.c.d에 대한 접근도 a.b.c에 대한 접근으로 간주한다.모서리 사례를 보여주는 예시들:
if consume {
x.foo().
}
관련한 경우, non-move 클로저는 이미 공유 참조로 캡처한다. 즉, 이후 그 변수를 사용하려는 시도는 일반적으로 성공한다:
let f = async {
// ----- NOT async move
self.some_a.alias()
};
do_something_else(self.some_a.alias());
// ----------- 나중 사용 성공
f.await;
이 future는 self.some_a의 alias를 생성하기 위해 소유권을 가질 필요가 없으므로, self.some_a에 대한 _참조_만 캡처한다. 따라서 이후 self.some_a의 사용도 문제없이 컴파일된다. 하지만 이것이 async move 클로저였다면, 위 코드는 현재 컴파일되지 않았을 것이다.
에지 케이스로, 당신이 _move_하려고 할 때는 오류가 날 수 있다:
let f = async {
self.some_a.alias()
};
do_something_else(self.some_a);
// ----------- move!
f.await;
이 경우에는 async move 클로저로 바꾸거나 명시적 캡처 절을 사용할 수 있다:
그렇다! 코드 생성 단계에서 Clone::clone 또는 Alias::alias 호출 후보를 식별한다. 빌림 검사 이후, 각 호출 지점을 살펴보고 빌림 검사 정보로 판단한다:
두 질문 모두 “아니오”라면, 그 호출을 원래 장소의 move로 대체한다.
예시는 다음과 같다:
fn borrow(message: Message) -> String {
let method = message.method.to_string();
send_message(message.clone());
// ---------------
// 다음으로 변환됨
// 단순히 `message`
method
}
fn borrow(message: Message) -> String {
send_message(message.clone());
// ---------------
// 변환 불가
// 왜냐하면 `message.method`가
// 이후에 참조되기 때문
message.method.to_string()
}
fn borrow(message: Message) -> String {
let r = &message;
send_message(message.clone());
// ---------------
// 변환 불가
// 왜냐하면 `r`이 `message`를
// 참조할 수 있고 이후에 사용되기 때문
r.method.to_string()
}
예전에는 마지막 사용 _변환_을 _최적화_라고 불렀지만, 여기서는 용어를 바꾼다. 일반적으로 _최적화_는 사용자에게 실행 시간 측정(혹은 UB)을 통해서만 관찰 가능해야 하지만, 여기서는 명백히 그렇지 않기 때문이다. 이 변환은 컴파일러가 기계적으로, 결정론적으로 수행하는 변환이다.
그렇다고 본다. 하지만 제한적으로. 다시 말해 다음과 같은
Clone::clone(&foo)
그리고
let p = &foo;
Clone::clone(p)
는 동일하게(즉 foo로 대체) 변환된다고 예상한다. 더 많은 중간 사용 수준에서도 마찬가지다. 이는 내가 상상하는 MIR 기반 최적화 기법에서 자연스럽게 “따라 나오는” 결과다. 꼭 이렇게 해야 하는 건 아니고, 사람들이 실제로 쓴 문법에 더 엄격하게 맞출 수도 있다. 하지만 그건 놀라울 것이다.
반면, 다음처럼 속일 수는 있다:
fn identity<T>(x: &T) -> &T { x }
identity(&foo).clone()
내가 상상하는 방식으로는, 아니오. 변환은 함수 본문에 국한된다. 즉, 다음처럼 force_clone 같은 함수를 작성하여 clone을 “숨길” 수 있으며, 이렇게 하면 그 clone은 절대 제거되지 않는다(에디션 변환에 중요한 능력이다!):
fn pipe<Msg: Clone>(message: Msg) -> Msg {
log(message.clone()); // <-- 이건 유지
force_clone(&message)
}
fn force_clone<Msg: Clone>(message: &Msg) -> Msg {
// 여기서 입력은 `&Msg`이므로, `Msg`를 생성하려면
// clone이 필요하다.
message.clone()
}
그럴 수 있다! 다음 예시를 보자. 명시적 캡처 절 표기와 Alias 트레이트가 있다고 가정한다:
async fn process_and_stuff(tx: mpsc::Sender<Message>) {
tokio::spawn({
async move(tx.alias()) {
// ---------- 여기서 alias
process(tx).await
}
});
do_something_unrelated().await;
}
Sender 값이 언제 drop되는지는 중요할 수 있다 — 모든 sender가 drop되면, Receiver는 recv 호출 시 None을 반환하기 시작한다. 그 전에는, tx 핸들이 여전히 사용될 수 있으므로 더 많은 메시지를 기다리며 블록한다.
그렇다면 process_and_stuff에서 sender alias들이 완전히 drop되는 시점은 언제인가? 답은 마지막 사용 변환을 하느냐에 따라 다르다:
tx와 future가 보유한 것. 따라서 리시버는 do_something_unrelated가 끝나고 그리고 태스크가 완료되었을 때에야 None을 반환하기 시작한다.tx.alias() 호출이 제거되어 alias는 하나뿐이다 — future로 이동된 tx 하나, 그리고 스폰된 태스크가 완료되면 drop된다. 이는 앞의 코드(두 조건이 모두 끝나야 함)보다 더 이르게 drop될 수 있다.대부분의 경우, 소멸자를 더 일찍 실행하는 것은 좋은 일이다. 최대 메모리 사용량이 줄고, 반응성이 빨라진다. 하지만 극단적인 경우에는 버그로 이어질 수 있다 — 전형적인 예가 Mutex<()>처럼 가드가 외부 자원을 보호하는 경우다.
이게 바로 에디션의 존재 이유다! 우리는 실제로 Rust 2021에서 아주 비슷한 변환을 했다. RFC 2229는 클로저 주변의 소멸자 타이밍을 변경했지만, 대체로 큰 사건이 아니었다.
에디션 호환성에 대한 욕구는 왜 이것을 _마지막 사용 변환_으로 만들고 어떤 _최적화_로 만들지 않으려는가에 대한 이유 중 하나다. 이 예시들에는 UB가 없다. 다만 clone/alias 주변에서 Rust 코드가 무엇을 하는지 이해하는 게 예전보다 약간 복잡해지는 것이다. 컴파일러가 해당 호출들에 자동 변환을 수행하기 때문이다. 이 변환이 함수에 국한된다는 사실은, 호출마다 이전 에디션 규칙(항상 호출됨)을 따를지, 새 에디션 규칙(이동으로 변환될 수 있음)을 따를지 선택할 수 있음을 의미한다.
이론적으로는 그렇다. Polonius 같은 빌림 검사 정밀도 개선이 마지막 사용 변환을 적용할 기회를 더 많이 포착하도록 만들 수 있다. 이는 에디션에 걸쳐 단계적으로 도입할 수 있다. 다소 번거롭지만 감당할 수 있다고 본다 — 그리고 실제로 중요한 문제일까는 확신하지 못하겠다. 예컨대 Polonius로 기대하는 개선을 떠올리며 생각해 보았지만, 영향받을 현실적인 예시를 떠올리기 어려웠다.
이 마지막 사용 변환은 빌림 검사를 통과하지 못하는 코드를 만들어내지 않는 것이 보장된다. 하지만, unsafe 코드의 정합성에는 영향을 줄 수 있다:
let p: *const T = &*some_place;
let q: T = some_place.clone();
// ---------- `some_place`가
// 나중에 사용되지 않는다면 move로 바뀐다
unsafe {
do_something(p);
// -
// 이제 이 포인터는 값이 초기화되지 않은
// 스택 슬롯을 가리킨다.
}
그러나 이 경우, some_place.clone() 호출이 단지 some_place로 변환될 것이라는 린트가 보고될 것이다. 또한 이런 단순한 예시는 감지하여, 우리가 흔히 그러듯 보장된 UB가 있는 경우에는 더 강한 기본 거부(deny-by-default) 린트를 보고할 수도 있다.
처음 이 아이디어를 떠올렸을 때, 나는 이를 “use-use-everywhere”라 불렀고, x.clone()이나 x.alias() 대신 x.use를 쓰는 상상을 했다. 키워드는 클로저 디슈가링에 영향을 준다는 더 강한 신호가 된다고 느꼈기 때문이다. 하지만 몇 가지 이유로 생각이 바뀌었다.
첫째, Santiago Pastorino가 x.use가 신규 학습자에게 걸림돌이 될 것이라고 강하게 반대했다. 그들은 이 키워드를 보고 그것이 무엇인지 이해해야 한다. 반면 메서드 호출을 보게 하면, 뭔가 이상하다고 눈치채지 못할 수도 있다.
둘째, lang-team 회의에서 TC가 주장했듯, ref-counted 값을 클로저에서 ergonomically clone해야 한다는 모든 논거는, 애플리케이션의 필요에 따라 clone에도 똑같이 적용된다. 전적으로 동의한다. 앞서 언급했듯, 이는 [Alias 트레이트에 대한 내가 들은 우려]도 해소한다. 즉, “alias”와 정확히 대응하지 않지만 여전히 ergonomically clone하고 싶은 것들이 있다는 우려다. 맞는 말이다.
일반적으로 clone(과 alias)은 Rust 사용에서 충분히 근본적이어서, 특별 취급을 해도 괜찮다고 본다. 어쩌면 앞으로 비슷한 메서드를 더 찾거나 이 메커니즘을 일반화하게 될지도 모르겠지만, 지금은 이 두 가지에 집중해도 된다고 생각한다.
내가 가끔 제기한 논점 하나는, 명백히 필요하지 않은 경우에는 참조 카운트를 증가시키지 않도록 컴파일러가 최적화의 여지를 더 얻는 해법을 원한다는 것이다. 예를 들어 다음과 같은 함수:
fn use_data(rc: Rc<Data>) {
for datum in rc.iter() {
println!("{datum:?}");
}
}
이 함수는 ref-counted 값의 alias 소유권을 요구하지만, 실제로는 읽기만 한다. 이런 호출자는…
use_data(source.alias())
…참조 카운트를 늘릴 필요가 사실 없다. 호출자는 내내 참조를 쥐고 있을 것이기 때문이다. 나는 종종 아래처럼 &를 사용해 코드를 작성한다:
fn use_data(rc: &Rc<Data>) {
for datum in rc.iter() {
println!("{datum:?}");
}
}
그러면 호출자는 use_data(&source)라고 쓸 수 있다 — 그러면 피호출 측은 소유권을 원한다면 rc.alias()를 쓸 수 있다.
이 문제는 일단 미루기로 했다. 성능에 아주 민감한 이들은 &Arc를 사용할 수 있고, 그 외의 우리는 때때로 참조 카운트 증가가 하나 더 생길 수 있다. 그럼에도 사용자 관점에서의 의미론은 충분히 명확하고(솔직히) 충분히 좋다고 본다.
clippy::pedantic에는 불필요한 clone을 위한 전용 린트가 없다. 이 예시는 린트가 발생하지만, 인자를 값으로 받고 나서 소비하지 않는다는 내용의 린트다. 예시를 로컬에서 id를 생성하도록 바꾸면, clippy는 불평하지 않는다.↩︎