Rust에 `Copy`와 `Clone` 옆에 세 번째 트레이트 `Claim`을 도입해 “저렴하고, 실패하지 않으며, 투명한” 복제를 구분하고, 컴파일러가 필요 시 자동으로 `claim()`을 삽입하며, 최종적으로 `Copy`를 이동 의미에서 분리하자는 제안. 이로써 유지보수 위험, 성능 함정, 그리고 클로저·async 캡처의 인지 부하를 줄이자는 내용이다.
이 블로그 글은 Copy와 Clone과 나란히 존재할 세 번째 트레이트 Claim을 도입하자고 제안한다. 이 트레이트의 목표는 Rust의 기존 구분—타입을 memcpy가 안전한 “plain old data”1인 Copy와, 사용자 정의 코드 실행이나 소멸자가 필요한 Clone으로 나누는 방식—을 개선하는 것이다. 이 구분은 Rust에 꽤 잘 맞아 왔지만, 시간이 지나며 유지보수 위험, 성능 발목잡이(footgun), 그리고 (때로 매우 큰) 인체공학적 고통과 사용자 혼란 같은 단점도 드러났다.
이 글의 제안은 세 단계로 이루어진다:
Clone을 정교화하여 “저렴하고, 실패하지 않으며, 투명한” 복제를 식별하는 새 트레이트 Claim을 추가한다(정의는 아래에 있음; 특히 할당을 명시적으로 배제). 따라서 x.claim() 호출은 저렴함이 보장되고, 그렇지 않을 수 있는 x.clone() 호출과 쉽게 구분된다. 이는 코드를 더 이해하기 쉽게 만들고 기존 유지보수 위험을 줄인다(이름은 당연히 자전거 보관소 논쟁 가능).claim() 호출을 삽입한다. 예컨대 변수 y: Rc<Vec<u32>>가 있을 때 x = y와 같은 대입은 y가 이후에 다시 쓰인다면 x = y.claim()으로 변환된다. 이는 오늘날 Rust에서 참조 카운팅 값과 관련된 인체공학적 고통과 사용자 혼란(특히 클로저와 async 블록에서)을 해결한다.Copy를 “이동(move)”과 완전히 분리한다. 현 에디션에서는 경고로, 이후 Rust 2027에서는 오류로 바꾼다. 요컨대 x = y는 y: Claim이 아니면 이동이 된다. 대부분의 Copy 타입은 Claim이기도 하므로 대체로 역호환되지만, y: [u8; 1024] 같은 경우를 배제하고, 또한 Cell<u32>나 이터레이터 같은 타입에 Copy를 확장하더라도 미묘한 버그를 도입하지 않게 된다.어떤 코드에서는 자동 Claim 호출이 바람직하지 않을 수 있다. 예컨대 일부 자료구조 정의는 참조 카운트 증가를 면밀히 추적한다. 이 경우를 위해, 기본 허용(allow-by-default)인 automatic-claim 린트를 만들어 크레이트나 모듈 단위로 opt-in하여 모든 “claim”을 명시적으로 만들 수 있게 하자고 제안한다. 이는 대체로 프로필 패턴과 유사하지만, 여기서는 “오토-클레임”을 원하는 크레이트 집합이 깔끔한 범주로 묶이지 않을 수 있다는 점이 주목할 만하다(뒤에서 논의).
Claim 트레이트 도입자, 이 코드를 읽고 성능 특성에 대해 무엇이라도 말할 수 있는가?
tokio::spawn({
// `map`을 클론해서 같은 이름의 다른 변수에 저장한다.
// 이 새 변수는 기존 변수를 가린다(shadowing).
// 이제 `map`을 사용하는 코드를 쓴 다음에도
// 원래의 것을 계속 사용할 수 있다.
let map = map.clone();
async move { /* code using map */ }
});
/* more code using map */
짧은 답: 아니다. map의 타입을 모르면 모른다. map.clone() 호출이 큰 맵을 깊게 복제하는 것일 수도 있고 참조 카운트를 1 증가시키는 것일 수도 있는데, 이 코드만 봐서는 알 수 없다.
코드를 작성하는 중에는 어느 값이 “복제가 싸다” 또는 “비싸다”는 감이 있다. 하지만 이 성질은 코드의 수명 동안 바뀔 수 있다. 처음에는 map이 Rc<HashMap<K, V>>였는데 나중에 HashMap<K, V>로 리팩터링될 수도 있다. map.clone() 호출은 여전히 컴파일되지만 성능 특성은 매우 다르다.
사실 clone은 프로그램의 의미(semantics)에도 영향을 줄 수 있다. 변수 c: Rc<Cell<u32>>와 호출 c.clone()이 있다고 하자. 현재는 같은 내부 셀을 가리키는 또 다른 핸들을 만든다. 그런데 c를 Cell<u32>로 리팩터링하면, 그 c.clone() 호출은 이제 독립적인 셀을 만든다. 이런! (나중에 보겠지만, 내부 변경 가능성(interior mutability)을 구분하는 중요성은 여러 번 다시 등장한다.)
Claim 트레이트이제 새 트레이트 Claim을 도입했다고 가정해 보자. 이는 Clone의 서브트레이트이며, 복제가 다음을 만족함을 나타낸다:
트레이트 자체는 다음처럼 정의될 수 있다:2
trait Claim: Clone {
fn claim(&self) -> Self {
self.clone()
}
}
이제 타입을 몰라도 map.claim() 호출을 보면 “저렴한 복제”임을 꽤 확신할 수 있다. 더구나 내 코드가 리팩터링되어 map이 더 이상 참조 카운팅되지 않는다면, 여기서 컴파일 오류가 발생하여 “여기서 clone(잠재적으로 비쌈)을 할지, 다른 해법을 찾을지”를 결정할 기회를 준다.
오늘날 Rust에서는 타입이 Copy 트레이트를 구현하지 않는 한 값을 사용하면 이동된다. 이는 (그 외도 있지만) map: Rc<HashMap<K, V>> 같은 참조 카운팅 맵에서, map 값을 한 번 사용하면 다시는 map을 사용할 수 없다는 뜻이다. 예컨대 some_operation(map)을 하면, 그 핸들을 some_operation에 넘겨버려 다시 사용할 수 없다.
이 규칙의 의도는 x = y처럼 단순한 대입이 런타임에서도 단순한 연산(구체적으로는 memcpy)을 의미하고, 확장 가능한 동작이 되지 않게 하자는 것이다. 이는 칭찬할 만하다. 하지만 현 규칙은 실제로 몇 가지 문제가 있다:
x = y가 런타임에서 놀라운 일을 일으킬 수 있다. 예컨대 y: [u8; 1024]라면, process1(y); process2(y);처럼 단순한 호출 몇 개만으로도 대량의 데이터를 복사하게 된다(아마 참조로 넘기려 했을 것이다).x = y.clone()(혹은 x = y.claim()) 같은 표기는 시각적 잡음이라 독자가 실제로 무슨 일이 일어나는지를 흐린다. 대부분의 애플리케이션에서 참조 카운트를 1 증가시키는 일은 그렇게까지 대서특필할 만큼 흥미롭지 않다.Copy가 되어야 할 것들 중 일부가 그렇지 않다더 미묘한 문제가 있다. 현 규칙은 Copy impl을 추가하는 것이 정합성 위험을 만들 수 있음을 뜻한다. 예컨대 std::ops::Range<u32>나 std::vec::Iter<u32> 같은 많은 이터레이터 타입은, memcpy가 안전하다는 의미에서 Copy가 될 수 있다. 그렇게 되면 Cell에 넣고 get/set으로 조작하는 것도 가능하니 멋질 것이다. 하지만 미묘한 발목잡이를 만들기 때문에 이 타입들에는 Copy를 구현하지 않는다:
let mut iter0 = vec.iter();
let mut iter1 = iter0;
iter1.next(); // `iter0`에는 영향이 없다
이게 놀라운가 아닌가는 Rust에 얼마나 익숙한지에 달려 있다 — 하지만 clone을 명시적으로 호출해야 했다면 훨씬 더 명확했을 것이다:
let mut iter0 = vec.iter();
let mut iter1 = iter0.clone();
iter1.next();
비슷한 고려가 바로 왜 Cell<u32>에 Copy를 구현하지 않았는지의 이유다.
그러나 clone/copy에서 가장 큰 혼란의 원천은 x = y 같은 대입이 아니라 클로저와 async 블록이다. 참조 카운팅 값과 클로저를 결합하는 것은 새 사용자에게 큰 걸림돌이다. 이건 아주 오래전부터 그랬다. 예컨대 2014년 Strangeloop 발표에서, 발표자는 클로닝과 클로저를(그리고, 덧붙이자면, clone이라는 용어가 깊은 복제를 의미하지 않기 때문에 오해를 부른다는 점도) 둘러싼 “우발적 복잡성”(그들의 표현이지만 동의한다)에 상당한 시간을 할애한다. 유감스럽게도 그 이후 상황은 크게 나아지지 않았다. 게다가 이 발표자는 숙련된 프로그래머다. 이제 초보자가 이걸 헤쳐나가려 한다고 상상해 보라. 으악.
하지만 고수에게도 쉽지 않다! 사실, 클로저에서 사용할 참조 카운팅 항목을 복제해야 하는 문제를 “편리하게” 다루는 방법이 딱히 없다. RustNL 언컨퍼런스에서 Jonathan Kelley—Dioxus Labs를 이끄는—는, CloudFlare 코드베이스에서 컨텍스트를 전달하는 가장 인체공학적인 방식을 찾기 위해 상당한 시간을 썼다고 설명했다(그들은 Rust 초보가 아니다).
그 환경에서, 그들은 여러 서브시스템을 가진 마스터 컨텍스트 객체 cx를 갖고 있었고, 각 서브시스템은 참조 카운팅되었다. 새로운 태스크를 시작하기 전, 그 태스크가 필요로 하는 서브시스템 핸들만 건네주었다(모든 태스크가 전체 컨텍스트를 잡고 있기를 원치 않았기 때문). 그들이 최종적으로 채택한 설정은 다음과 같았는데, 여전히 꽤 고통스럽다:
let _io = cx.io.clone():
let _disk = cx.disk.clone():
let _health_check = cx.health_check.clone():
tokio::spawn(async move {
do_something(_io, _disk, _health_check)
})
섀도잉을 활용하면 (내 생각에) 약간 나아지지만 그래도 꽤 장황하다:
tokio::spawn({
let io = cx.io.clone():
let disk = cx.disk.clone():
let health_check = cx.health_check.clone():
async move {
do_something(io, disk, health_check)
}
})
정말로 원하는 것은 Swift나 Go, 다른 대부분의 현대 언어에서 하듯 다음처럼 간단히 쓰는 것이다:3
tokio::spawn(async move {
do_something(cx.io, cx.disk, cx.health_check)
})
내 제안은 차용 검사기가 필요 시 자동으로 claim을 호출하게 만드는 것이다. 예컨대 x = y 같은 표현은 y가 이후 다시 쓰인다면 자동으로 x = y.claim()으로 바뀐다. 또한 클로저가 환경의 변수를 캡처할 때도 오토클레임을 적용하여, move || process(y)는 y가 이후 다시 쓰인다면 { let y = y.claim(); move || process(y) }로 바뀐다.
오토클레임은 변수의 마지막 사용에는 적용되지 않는다. 따라서 x = y는 오류를 막기 위해 필요할 때만 claim 호출을 도입한다. 이는 불필요한 참조 카운팅을 피한다.
물론 y의 타입이 Claim을 구현하지 않았다면, 이것은 이동이라는 점을 설명하고, 복제된 값을 원한다면 사용자가 clone 호출을 넣으라는 적절한 오류를 내보낼 것이다.
기존 핸들을 “이동”하는 것과 새 핸들을 “클레임”하는 것의 구분을 명시적으로 하는 편이 유익한 코드도 분명 있다. 이런 경우를 위해, 컴파일러가 Copy가 아닌 타입에 대해 claim 호출을 삽입할 때마다 트리거되는 기본 허용(allow-by-default) 린트 automatic-claim을 추가하자고 생각한다. 이는 사용자 정의 코드가 실행된다는 신호다.
발견을 돕기 위해, 이런 “거의 항상 유용하지만 때로는 아니라서” 편의 기능들을 묶는 automatic-operations 린트 그룹을 고려할 만하다. 사실상 한때 제안했던 프로필 패턴을 린트 그룹으로 채택하는 셈이다. 그러면 크레이트는 Cargo.toml의 [lints] 섹션에 automatic-operations = 'deny'(명칭은 자전거 보관소 논쟁 필요)를 추가할 수 있다.
Copy를 쓰지 말자오토클레임을 도입하면 clone 호출 필요성에 대한 인체공학적 문제는 해결되지만, 여전히 Copy인 것은 뭐든 복사될 수 있다. 앞서 말했듯 이는 성능 발목잡이([u8;1024]는 가볍게 복사할 게 아니다)와 정합성 위험(이터레이터도 마찬가지)을 뜻한다.
진짜 목표는 “memcpy가 가능함”과 “자동 복사가 가능함”을 분리하는 것이다4. 오토클레임이 있으면, 린트와 에디션의 마법 덕분에 이를 할 수 있다:
x = y가 Claim은 아니지만 Copy인 값을 복사하면 경고한다.Claim 트레이트에만 묶는다.코드 생성 단계에서는, 여전히 x = y가 memcpy이며 y.claim()을 호출하지 않는다는 보장을 유지하고 싶다. 기술적으로 Clone 구현이 동일 동작이라는 보장이 없기 때문이다. 이 보장을 임의의 clone 호출에도 확장할 수 있으면 좋겠지만, 방법을 모르겠고 별개의 문제다. 더 나아가 automatic_claims 린트는 Copy를 구현하지 않은 타입에만 적용될 것이다.5
좋다, 제안을 펼쳐 보였으니, 흔히 나오는 질문에 답해 보겠다.
글쎄, 그럴지도? Copy/Clone 구분은 오래전부터 Rust의 일부였다6. 하지만 실제 코드베이스와 일상에서 보기에, 이 변화의 영향은 전반적으로 순이익일 것 같다:
claim 호출(저렴, 실패 없음, 투명)과 clone 호출(뭐든 가능)을 구분할 수 있다.이게 뭐가 나쁘지?
#[deny(automatic_claims)]를 쓸까?흥미로운 질문이다! 처음엔 이를 “고수준 비즈니스 로직” 대 “저수준 시스템 소프트웨어”의 구분과 대응시켰는데, 지금은 확신이 덜하다.
예컨대 Rust For Linux의 어느 분과 이야기했는데, 그분은 오토클레임이 유용하다고 느꼈다. 그보다 더 저수준인 곳이 있을까! 그들의 기본 제약은 메모리 할당과 다른 실패 가능한 연산이 어디서 일어나는지 면밀히 추적하고 싶다는 것이고, 참조 카운트 증가는 괜찮다.
아마 정답은 “잘 모르겠다, 지켜봐야 한다!”일 것이다. 꽤 작고 특화된 프로젝트 집합일 것으로 짐작한다. 그래서 이게 좋은 아이디어라고 생각한다.
완전히 이해한다! 사실 이 제안은 당신의 코드에 실질적으로 도움이 된다:
#![deny(automatic_claims)]를 설정함으로써, 참조 카운트를 면밀히 추적한다는 사실을 애초에 선언한다. 물론 모두가 이를 장점으로 보진 않겠지만, 어쨌든 한 번의 설정 비용이다.claim과 clone을 구분함으로써, 프로젝트는 놀라운 성능 발목잡이를 피한다(이건 논란의 여지 없이 좋다).Copy가 더 이상 묵시적으로 복사하지 않게 되면, 그에 따른 발목잡이도 더 피하게 된다(이 또한 논란의 여지 없이 좋다).오, 깊은 컷! RFC 936은 Pod(memcpy 가능한 값)과 Copy(묵시적으로 memcpy 가능한 값)를 분리하자는 제안이었다. 그때 우리는 그렇게 하지 않기로 결정했다7. 심지어 그 결정을 요약한 사람은 나였다. 요점은, 단일 트레이트와 린트를 유지하는 편이 낫다고 봤다는 것이다.
나는 확실히 그 RFC가 겨냥한 같은 문제를 다른 해법으로 제시하고 있다. 당시 우리가 틀렸다고 생각하진 않는다. 문제는 실제였지만 제안된 해법이 그만한 가치가 없었다. 이 제안은 같은 문제들을, 아니 그 이상을 해결하며, ~10년의 경험이란 이점도 있다8. (또한 이 RFC는 1.0 두 달 전의 일이고, 나는 1.0을 막판 변경으로 탈선시키지 않으려 했다는 점을 분명히 느낀다 — 정지 없는 안정!)
좋은 질문이다. 기술적으로는 새로울 게 없다. 린트는 예전부터 있었고, 많은 프로젝트가 이를 다양한 방식으로 사용해 왔다(예: 맞춤형 clippy 레벨, 심지어 — 리눅스 커널처럼 — 전용 커스텀 린터). 중요한 불변식은 린트가 Rust의 “부분집합”을 정의할 뿐, Rust 자체를 바꾸지 않는다는 것이다. 어떤 코드든, 컴파일되는 한 그 의미는 언제나 같다.
그렇다고 해도, 프로필 패턴은 문법 설탕을 도입하는 비용을 낮춘다. 여기에 “미끄러운 경사”가 있다고 본다. 나는 Rust가 근본적으로 성격을 바꾸는 것을 원하지 않는다. 우리는 여전히 성능, 신뢰성, 장기 유지보수를 우선하는 프로그램이라는 핵심 고객층을 지향해야 한다.
설계 공리를 써 보자. 그런데 초안은 이미 있다! 몇 해 전 Aaron Turon이 “인체공학 이니셔티브” 블로그 글에서 날카로운 분석을 했다. 그는 세 가지 축을 제시했다:
- 적용 범위(Applicability). 암시된 정보를 어디서 생략할 수 있는가? 그것이 일어날 수 있다는 예고가 있는가?
- 파워(Power). 생략된 정보가 끼치는 영향은 무엇인가? 프로그램의 동작이나 타입을 급격히 바꿀 수 있는가?
- 문맥 의존성(Context-dependence). 암시된 것이 무엇인지 알기 위해 코드의 나머지를 얼마나 알아야 하는가? 생략된 세부 사항이 채워지는 명확한 장소가 항상 있는가?
Aaron은 결론 내리길, “암시적 기능은 이 세 차원을 균형 있게 가져야 한다. 어느 한 차원이 크다면, 다른 두 개를 강하게 제한하는 것이 좋다.” 오토클레임의 경우, 적용 범위는 크다(예고 없이 자주 일어날 수 있음) 그리고 문맥 의존성은 중간에서 큼 사이(타입과 그 트레이트 구현을 알아야 함)다. 따라서 파워를 제한해야 하며, 이것이 Claim을 구현할 수 있는 주체에 대해 명확한 가이드라인을 두는 이유다. 물론 그것으로 충분치 않은 경우를 위해 린트로 적용 범위를 0으로 줄일 수 있다.
이 분석이 마음에 든다. 또한 “누가 왜 옵트아웃하고 싶어 하는가”를 고려하고, 간단한 조치(예: 할당 금지)를 통해 이를 최소화하면서 기능의 전반적 유용성은 유지하려고 한다.
최근 랭 팀 미팅에서 Josh가 “캡처하는 것을 자동으로 캡처한다”는 의미의 문법을 클로저(그리고 아마 async 블록)에 주자는 아이디어를 제시했다. 개념은 매력적이다. 자동 문법의 명시적 버전을 갖는 것을 좋아하고, automatic_claim을 거부하는 프로젝트에도 더 가벼운 대안이 필요하기 때문이다. 그러나 구체적 제안을 본 적이 없고, 스스로도 그 무게를 견딜 만한 제안을 떠올리지 못했다. 그래서 내 입장은 “좋다, 마음에 든다. 다만 이 글의 제안을 대체가 아니라 보완하는 형태라면”이다.
좋은 질문! 내 마음을 읽은 듯하다! 방금 전 항목에 덧붙여 말하자면, “명시적 캡처 절” 문법은 나도 좋아한다.
오늘날 우리는 || $body(이는 $body에서 등장하는 경로를 어떤 모드로든 암시적으로 캡처)와 move || $body(이는 $body에서 등장하는 경로를 값으로 암시적으로 캡처)만 있다.
몇 년 전 hackmd에 초안 RFC를 썼는데, 지금도 대체로 마음에 든다(세부는 다시 보고 싶다). 아이디어는 move를 확장해 무엇을 캡처하는지 더 명시적으로 쓰게 하자는 것이다. 예컨대 move(a, b) || $body는 오직 a와 b만 값을 통해 캡처하고(그리고 $body가 다른 변수를 참조하면 오류), move(&a, b) || $body는 a = &a로 캡처한다. 그리고 move(a.claim(), b) || $body는 a = a.claim()으로 캡처한다.
이건 사실 “클로저 캡처에 명시적 형태가 없다”는 다른 문제를 겨냥하지만, 동시에 주변 컨텍스트에서 값을 “클레임”하는 정석적이고 가벼운 패턴을 제공한다.
Claim이라는 이름은 어떻게 나왔나?처음엔 Jonathan Kelley가 내게 제안한 줄 알았는데, 메모를 보니 그가 제안한 건 Capture였다. 음, 그것도 좋은 이름이다. 어쩌면 더 나은 이름일지도! 하지만 이 빌어먹을 글 전체를 이미 Claim이라는 이름으로 써 버렸으니, 지금 와서 바꾸진 않겠다. 실제 행동에 옮기기 전엔 제대로 자전거 보관소 논쟁을 기대한다.
위키피디아를 사랑한다(물론). 하지만 passive data structure라는(처음 듣는다) 이름을 plain old data 대신 쓰는 건… 아주, 아주 _위키피디아_스럽다.↩︎
사실 claim 메서드를 “final”로 정의해 구현체가 오버라이드할 수 없도록 하여, x.claim()과 x.clone()이 동일하다는 보장을 갖고 싶다. 이는 확장 트레이트에 claim을 정의하는 다소 어색한 방식으로 어느 정도 가능하다. 예시. 다만 이를 표준 라이브러리에 두는 건 조금 민망할 것이다.↩︎
흥미롭게도, 저 코드 조각을 읽다가 “async move { do_something(cx.io.claim(), ...) }여야 하지 않을까?”라는 생각이 스쳤다. 하지만 물론 그건 안 된다. 그건 “미래” 안에서 클레임하는 것이고, 우리가 원하는 건 “이전”에 하는 것이다. 하지만 정말로 그럴듯해 보이고, 이 주제가 얼마나 비직관적인지 보여주는 좋은 증거다.↩︎
사실상 RFC 936에서 내렸던 결정을 다시 보는 셈이다. 더 할 말이 있지만, FAQ로 남겨 두겠다
오, 아이디어가 떠올랐다. x.claim()을 쓰는 것 외에 x.copy()( iter.copied()와 유사)를 써서 “지금은 memcpy를 한다”를 명시해도 좋겠다. 그럼 컴파일러 규칙은, Claim을 구현한 타입에 대해 상황에 맞게 x.claim() 또는 x.copy()를 삽입하는 식이 된다.↩︎
나는 오래된 설계 결정을 다른 이들보다 더 기꺼이 재검토하려는 편임을 자주 느낀다. 그 결정들이 내려질 때 그 자리에 있었기 때문일 것이다. 대부분 아슬아슬했고, 종종 “한동안 이렇게 해 보고 느낌을 보자…”로 시작했다는 걸 알기 때문이다. 음, 그런 성향과, 약간의 무모함 성향 덕분일지도.↩︎
이는 Rust 의사결정의 장단을 비춘다. 장점인 이유는, 내 생각에 오토클레임이 두 극단 사이의 멋진 “제3의 길”이기 때문이다. 단점인 이유는, 오토클레임의 대략적 설계는 수년 전부터 명확했지만 실제로 행동으로 옮기기까지 오랜 시간이 걸렸다는 점이다. 아마 본질이 그런 것일지도.↩︎