Rust에서 참조 카운팅을 더 편리하게 만들기 위한 "그 트레이트"의 이름과 의미를 재정의한다. 값이 아니라 같은 기저 값에 대한 또 하나의 핸들을 만든다는 의미를 드러내기 위해 Handle 트레이트를 제안하고, 얽힘(entanglement)이라는 개념과 clone 대신 handle를 권장하는 린트 전략을 설명한다.
최근에 편리한 참조 카운팅을 둘러싼 논의가 많았습니다. 랭 팀 디자인 미팅도 있었고 RustConf Unconf에서도 꽤 영향력 있는 토론이 있었죠. 몇 주째 후속 글을 쓰고 있었는데, 오늘 문득 처음부터 분명했어야 할 사실을 깨달았습니다. 글 쓰는 데 그렇게 오래 걸린다면 글이 너무 길다는 뜻이라는 걸요. 그래서 개별적인 핵심과 생각에 집중한 짧은 글 여러 편으로 나눠 보려 합니다. 첫 번째 글에서는 (a) 배경을 다시 짚고 (b) 흥미로운 질문, 즉 "그 트레이트를 무엇이라 부를 것인가"를 이야기하려 합니다. 제목이 시사하듯 제 제안은 Handle입니다 — 하지만 이건 잠시 뒤에 자세히 다루겠습니다.
이 논의를 처음부터 따라오지 않으신 분들을 위해, 어떻게 하면 편리한 참조 카운팅을 제공할 수 있을지에 관한 논의가 계속되어 왔습니다:
Claim이라 부른 트레이트에 관한 블로그 글 시리즈를 썼습니다.use 키워드와 use || 클로저를 제안하는 RFC #3680을 열었습니다. 반응은, 제 생각엔, 엇갈렸습니다. 실제 문제를 다루고 있다는 점엔 동의했지만 접근 방식에는 우려가 많았죠. 핵심 포인트는 여기에서 정리했습니다.이번 글의 초점은 한 가지 질문입니다. “그 트레이트”를 무엇이라 불러야 할까요? 사실상 모든 설계에서 _무언가_를 식별하는 어떤 종류의 트레이트가 존재해 왔습니다. 하지만 그 _무언가_가 정확히 무엇인지 파악하기1이 어려웠습니다. 이 트레이트는 무엇을 위해 존재하고 어떤 타입이 이를 구현해야 할까요? 분명한 것 하나는, 그 트레이트가 무엇이든 Rc<T>와 Arc<T>는 구현해야 한다는 점입니다. 하지만 그게 전부였죠.
제가 처음 제안했던 트레이트는 Claim이라는 이름이었고, “가벼운 clone”을 전달하려는 의도였지만 — 사실 이 트레이트는 어떤 clone이 명시적이어야 하는지에 대한 정의로서 Copy를 대체하려는 목적2이었습니다. Jonathan Kelley도 비슷한 제안을 했지만 이름을 Capture라고 했죠. RFC #3680에서는 그 트레이트 이름을 Use로 제안했습니다.
세부와 의도는 달랐지만, 이 시도들의 공통점이 하나 있었습니다. 매우 _운영적(operational)_이었다는 점입니다. 즉, 그 트레이트는 항상 무엇을 하는지(혹은 하지 않는지)에 의해 정의되었지, 왜 그렇게 하는지에 의해 정의되지는 않았습니다. 그리고 이런 접근은 이런 종류의 트레이트에 대한 기반으로는 늘 약하며, 혼란과 다양한 해석에 취약합니다. 예를 들어 “가벼운” clone이란 무엇일까요? O(1)인가요? 그렇다면 아주 높은 확률로 O(1)인 것들은요? 물론 O(1)은 _저렴함_을 의미하지 않습니다 — 매 호출마다 22GB의 데이터를 복사할 수도 있죠. 그것도 O(1)입니다.
우리가 원하는 건, 취향이나 주관적 기준이 아니라 언제 구현해야 하고 언제 구현하지 말아야 하는지가 꽤 명확한 트레이트입니다. 그리고 Claim 등은 그 기준을 충족하지 못했습니다. Unconf에서, 제 설명만으로 자신의 타입이 그 트레이트(이름이 무엇이든)를 구현해야 하는지 판단하기가 매우 어렵다고 느낀 새로운 Rust 사용자들이 여럿 있었습니다. 이건 RFC와 다른 곳에서도 계속된 주제였습니다.
하지만 사실 여기에 _의미론적 토대_가 있긴 합니다. 이를 가장 먼저 제시한 사람은 Jack Huey였습니다. 다음 질문을 생각해 봅시다. Mutex<Vec<u32>>를 복제하는 것과 Arc<Mutex<Vec<u32>>>를 복제하는 것의 차이는 무엇일까요?
하나는 물론 비용입니다. Mutex<Vec<u32>>를 복제하면 벡터를 깊게 복제하지만, Arc를 복제하면 참조 카운트를 증가시키기만 합니다.
하지만 더 중요한 차이는 제가 _“얽힘(entanglement)”_이라고 부르는 것입니다. Arc를 복제해도 새 값을 얻는 게 아닙니다 — _같은 값에 대한 두 번째 핸들_을 얻게 됩니다.3
어떤 값들이 “얽혀” 있는지 아는 것은 프로그램이 무엇을 하는지 이해하는 데 핵심입니다. 빌림 검사기4가 신뢰성을 달성하는 큰 부분은 “얽힘”을 줄이는 데 있습니다. Rust에서는 얽힘을 다루는 것이 상대적으로 번거롭기 때문이죠.
다음 코드를 보세요. l_before와 l_after의 값은 무엇일까요?
let l_before = v1.len();
let v2 = v1.clone();
v2.push(new_value);
let l_after = v1.len();
답은 물론 “v1의 타입에 따라 다르다”입니다. v1이 Vec라면 l_after == l_before입니다. 하지만 v1이 다음과 같은 구조체라면:
struct SharedVec<T> {
data: Arc<Mutex<Vec<T>>>
}
impl<T> SharedVec<T> {
pub fn push(&self, value: T) {
self.data.lock().unwrap().push(value);
}
pub fn len(&self) -> usize {
self.data.lock().unwrap().len()
}
}
l_after == l_before + 1이 됩니다.
SharedVec처럼 동작하는 타입은 많습니다. 물론 Rc와 Arc도 그렇고, Bytes나 Sender와 같은 채널 엔드포인트도 마찬가지입니다. 이 모두는 기본 값에 대한 “핸들”의 예이고, 이를 복제하면 첫 번째 것과 구분되지 않는 두 번째 핸들을 얻게 됩니다.
Jack의 통찰은 _구현 방식(어떻게)_이 아니라 _의미적 개념(공유됨)_에 초점을 맞추자는 것이었습니다. 이렇게 하면 그 트레이트를 언제 구현해야 하는지가 명확해집니다. 저는 이 아이디어가 매우 마음에 들었지만, 결국 Share라는 이름은 마음에 들지 않았습니다. 그 단어가 충분히 구체적이지 않고, 사용자가 특정 개념을 가리킨다는 걸 알아차리지 못할 수도 있다고 느꼈기 때문입니다. “공유 가능한 타입”은 어감이 애매하죠. 하지만 사실 이 개념을 가리키는 이름은 이미 널리 쓰이고 있습니다: 핸들들(handle)입니다(예: tokio::runtime::Handle).
이렇게 해서 제가 “그 트레이트”의 이름과 정의로 제안하게 된 것이 바로 Handle입니다:5
/// 이 타입이 어떤 기저 리소스에 대한 *핸들*임을 나타냅니다.
/// `handle` 메서드는 새로운 핸들을 얻는 데 사용됩니다.
trait Handle: Clone {
final fn handle(&self) -> Self {
Clone::clone(self)
}
}
handle을 호출하도록 권장할 것입니다Handle 트레이트는 항상 clone과 동등한 동작을 하는 handle 메서드를 포함합니다. 이 메서드의 목적은 결과가 같은 기저 값에 대한 두 번째 핸들임을 독자에게 신호하는 것입니다.
Handle 트레이트가 존재하면, 수신자가 Handle을 구현하는 것이 확실한 상황에서 clone 호출에 대해 린트를 발생시키고 handle 호출을 권장할 수 있습니다:
impl DataStore {
fn store_map(&mut self, map: &Arc<HashMap<...>>) {
self.stored_map = map.clone();
// -----
//
// 린트: 더 큰 명확성을 위해 `clone`을 `handle`로 바꾸세요.
}
}
위 코드를 린트가 제안하는 대로 handle을 사용해 바꾸면, handle이 상황을 얼마나 명확하게 만드는지 감이 오실 겁니다:
impl DataStore {
fn store_map(&mut self, map: &Arc<HashMap<...>>) {
self.stored_map = map.handle();
}
}
_핸들_의 결정적 특징은, 이를 복제하면 같은 기저 값에 접근하는 두 번째 값을 만든다는 점입니다. 즉 두 핸들은 “얽혀” 있으며, 한쪽에 영향을 주는 내부 가변성(interior mutability)이 다른 쪽에서도 드러납니다. 이를 반영해 대부분의 핸들은 API가 전부 혹은 대부분 &self 메서드로 이루어져 있습니다. 왜냐하면 _핸들_에 대한 유일 접근권을 가진다고 해서 _값_에 대한 유일 접근권을 보장하진 않기 때문입니다.
핸들은 의미론적으로 볼 때 대체로 내부 가변성이 관여될 때만 중요합니다. 불변 값에 대한 두 개의 핸들을 갖는 것이 _문제_는 아니지만, 대개 같은 값을 두 번 복사한 것과 구분되지 않습니다. 이는 영속 컬렉션을 흥미로운 회색 지대로 만듭니다. 저는 아마 im::Vec<T> 같은 것에는 Handle을 구현할 것 같습니다. 특히 im::Vec<Cell<u32>>처럼 얽힘이 보이는 경우가 있을 수 있으니까요. 다만 반대 의견도 가능하다고 봅니다.
표준 라이브러리에서는, 핸들은 정확히 하나의 Copy 타입(다른 것들은 값입니다)에 대해 구현될 것입니다:
// 공유 참조는 복제(또는 복사)될 때
// 두 번째 참조를 만듭니다:
impl<T: ?Sized> Handle for &T {}
참조 카운팅 포인터들(단, Box는 아님)에 대해서도 구현됩니다:
// 참조 카운팅 포인터는 복제될 때
// 두 번째 참조를 만듭니다:
impl<T: ?Sized> Handle for Rc<T> {}
impl<T: ?Sized> Handle for Arc<T> {}
그리고 내부적으로 참조 카운팅 값으로 구현된 채널 엔드포인트 같은 타입에도 구현됩니다:
// mpsc의 "sender"는 복제될 때,
// 같은 기저 채널에 대한 두 번째 sender를 만듭니다:
impl<T: ?Sized> Handle for mpsc::Sender {}
좋습니다, 이 “바이트 크기” 블로그 글은 여기까지 하겠습니다. 다음 편도 있어요! 하지만 그 전에, 이 설계를 위해 채택하면 유용하다고 믿는 “설계 공리”를 하나 제시하겠습니다:
얽힘을 드러내라. 기저 값에 대한 _핸들_과 그 값 자체의 차이를 이해하는 것은 Rust가 어떻게 동작하는지를 이해하는 데 필수적이다.
문장이 약간 어색하게 느껴질 수도 있지만, 핵심은 바로 이 부분이라고 생각합니다.
그렇습니다, 여러분. 이것이 바로 _복선_입니다. 내가 봐도 난 참 잘해요.↩︎
저는 Claim을 일종의 “가벼운 clone”이라고 묘사했지만, Unconf에서 누군가 “무거운 copy”가 제가 노린 바를 더 잘 설명한다고 지적했습니다.↩︎
우연의 일치가 아닌데, 복제가 얽힘으로 이어지는 타입들은 대체로 복제가 저렴한 타입들입니다.↩︎
그리고 함수형 프로그래밍도요…↩︎
final 키워드는 Josh Triplett이 RFC 3678에서 제안했습니다. 이는 구현체들이 Handle::handle의 정의를 변경할 수 없음을 의미합니다. 이름을 바꾸거나 더 일반화해야 하는지 등 이와 관련해 이런저런 논의가 있었고 앞으로도 있겠지만, 제가 아는 것은, 이런 경우처럼 메서드가 사용 가능 하도록 사용자가 opt-in할 수 있게 하되 무엇을 하는지는 바꾸지 못하게 하려면 매우 유용한 개념이라는 점입니다. 다른 방식으로도 할 수 있지만, 더 낯설어집니다.↩︎