자기 참조 타입(SRT)을 위해 필요한 언어 기능을 재검토하고, 경로(장소) 기반 수명, 자동 참조 안정성, &own/Relocate, Transfer 등의 아이디어로 힙·스택 기반 SRT를 더 간소화하는 방안을 제시한다.
지난 글에서 Rust에 사용하기 편한 자기 참조 타입(SRT)을 어떻게 도입할 수 있을지 논의했는데, 이미 어떤 형태로든 원하고 있는 기능들을 도입하는 방식이 주가 되었습니다. 열거했던 기능은 다음과 같습니다.
'unsafe와 'self 수명.super let / -> super Type).Move 자동 트레이트(!Move).이 글은 꽤 좋은 반응을 얻었고, 뒤따른 논의도 무척 흥미로웠습니다. 설계를 더 다듬는 데 도움이 될 만한 여러 점을 배웠고, 이를 정리해 두면 좋겠다고 생각했습니다.
!Move인 것은 아니다Niko Matsakis는 모든 자기 참조 타입이 반드시 !Move일 필요는 없다고 지적했습니다. 예를 들어 참조하는 데이터가 힙에 할당되어 있다면, 해당 타입은 실제로 !Move일 필요가 없습니다. 프로토콜 파서를 작성할 때도 데이터를 먼저 힙에 할당된 타입에 읽어들이는 일이 꽤 흔하죠. 기능적으로 볼 때, 상당수의 자기 참조 타입은 실제로 !Move나 어떤 형태의 Move 개념도 필요로 하지 않을 가능성이 큽니다. 이는 곧 타입을 제자리에서 구성하기 위해 super let / -> super Type 같은 무언가가 꼭 필요하지 않다는 뜻이기도 합니다.
힙에 할당된 타입에 대해 자기 참조를 허용하고 싶기만 하다면, 필요한 것은 그것들을 초기화하는 방법(뷰 타입)과 자기 수명(self-lifetime)을 기술할 수 있는 능력(최소한 'unsafe)뿐입니다. 이는 제한된 형태의 자기 참조를 우선 활성화하기 위해 무엇을 우선순위로 둘지에 대한 좋은 감을 줍니다.
'self 수명은 충분하지 않다수명 얘기가 나온 김에, ['self만으로는 충분하지 않을 것](https://www.reddit.com/r/rust/comments/1dsoaw2/comment/lb5hmpg/)이라는 Mattieum의 지적이 있었습니다. 'self`는 구조체 전체를 가리키며, 이는 실제로 사용하기에는 너무 조잡한 단위가 됩니다. 대신 개별 필드를 가리켜 수명을 기술할 수 있어야 합니다.
알고 보니 Niko도 장소(place) 기반 수명이라는 형태로 이를 위한 기능을 고안해 두었더군요. 우리가 값과 연결할 때 사용하는 'a와 같은 추상 수명 대신, 참조는 언제나 암묵적이고 유일한 수명 이름을 가지는 편이 더 낫습니다. 이에 접근할 수 있다면, 지난 글의 동기 예제에서 사용했던 'self 기반 접근을 다음과 같이 고쳐 써야 합니다.
struct GivePatsFuture {
resume_from: GivePatsState,
data: Option<String>,
name: Option<&'self str>, // ← 여기서 `'self` 수명에 주목
}
이제 경로 기반으로 바꿉니다.
struct GiveManyPatsFuture {
resume_from: GivePatsState,
first: Option<String>,
data: Option<&'self.first str>, // ← 여기서 `'self.first` 수명에 주목
}
이 예제만 보면 별로 중요해 보이지 않을 수도 있습니다. 하지만 가변성이 등장하는 순간 상황은 급격히 꼬입니다. 마법 같은 'self를 도입하는 대신 항상 'self.field를 요구하는 쪽이 전반적으로 더 나아 보입니다. 이를 위해서는 장소 기반 수명이 필요하고, 이건 그 자체로도 훌륭한 아이디어입니다.
앞서 힙에 값을 저장하는 자기 참조 타입에는 실제로 !Move를 부호화할 필요가 없다고 봤습니다. 모든 자기 참조 타입이 그런 것은 아니지만, 상당수를 설명하죠. 그럼 남은 거의 모든 자기 참조 타입에서도 !Move를 굳이 부호화할 필요가 없게 만들 수는 없을까요?
그게 이동 생성자처럼 들린다면 맞습니다 — 단, 한 가지 함정이 있습니다! 지난 글에서 설명했던 Relocate 트레이트와 달리, DoveOfHope의 지적대로라면 그마저도 필요 없을 수 있습니다. 컴파일러가 우리가 구조체 내부의 필드를 가리키고 있다는 걸 이미 안다면, 구조체를 이동할 때 포인터를 업데이트하도록 보장해 줄 수 있지 않을까요?
저는 이 가능성에 회의적이었는데, 장소 기반 수명에 대해 읽고 생각이 바뀌었습니다. 그 정도의 세분성이 있다면 이동 시 어떤 필드를 어떻게 업데이트해야 하는지 알 수 있을 것 같습니다. 비용 면에서 보면, 이동 때 포인터 값을 갱신하는 것뿐이므로 사실상 공짜에 가깝습니다. 그리고 !Move를 거의 완전히 없앨 수 있습니다.
여기서 커버되지 않는 경우는 'unsafe 참조나 스택 데이터로의 실제 *const T / *mut T 포인터입니다. 컴파일러는 그것들이 무엇을 가리키는지 알 수 없고, 따라서 이동 시 업데이트할 수 없습니다. 이를 위해서는 어떤 형태로든 Relocate 트레이트가 유용해 보입니다. 다만 이것도 당장 추가해야 할 필요는 없습니다.
이 섹션은 2024-07-08에 게시 후 추가되었습니다.
컴파일러가 예컨대 mem::swap에 대해서는 올바른 코드 생성을 보장할 수 있어도, ptr::swap 같은 raw 포인터 연산에 대해서까지 같은 보장을 할 수는 없습니다. 그리고 기존 구조체들이 내부적으로 그런 연산을 자유롭게 사용하고 있을 수 있기 때문에, 힙 기반 SRT와 달리 스택 상의 자기 참조 타입을 아무런 단서 없이 그냥 동작하게 만들 수는 없습니다. 이것은 실제로 문제이고, 이를 지적해 준 The_8472께 감사드립니다.
힙 기반 SRT와 경험을 맞추고 싶어 추가 바운드를 피하고 싶었지만, 그게 불가능해 보입니다. 따라서 어떤 최소한의 바운드를 도입하고, 이를 에디션을 거치며 기본(예: Sized)으로 뒤집는 방식이 도움이 될 수 있겠습니다. 현재로서는 대략 다음과 같은 생각을 하고 있습니다.
Relocate를 보완하는 새로운 자동 마커 트레이트 Transfer를 도입합니다. 이는 Rust의 Destruct / Drop 시스템의 쌍으로 위치합니다. 사람들이 사용할 바운드 이름은 Transfer이고, Relocate는 Transfer 시스템을 확장할 후크를 제공합니다.'self 수명을 가진 모든 타입은 자동으로 Transfer를 구현합니다.+ Transfer가 포함된 바운드만 impl Transfer 타입을 받을 수 있습니다.impl Transfer 타입에 대해 추가적인 안전 불변식을 지켜야 합니다.+ Transfer를 지원하도록 업데이트합니다.T: Transfer → T: ?Transfer).auto trait Transfer {}
trait Relocate { ... }
이런 걸 피하고 싶었는데, 이것이 정말로 이동 불가 타입보다 단순한지조차 의문을 던집니다. 하지만 the_8472의 지적대로 문제는 분명 존재하고 해결해야 합니다. 다행히 const에서도 비슷한 과정을 거친 바 있습니다. 또한 이것을 일반화할 수 있을 것 같지도 않습니다. 이에 대해서는 나중에 더 글을 쓰겠습니다.
Relocate는 아마 &own self를 받아야 한다설사 사용자가 직접 포인터 업데이트 로직을 작성해야 할 일이 사실상 전혀 없다고 하더라도, 해당 기능은 제공되어야 하고 제공할 때에는 제대로 인코딩해야 합니다. Nadrieril이 유용한 지적을 해 주었는데, Relocate 트레이트의 &mut self 바운드는 실제로 우리가 원하는 것이 아닐 수 있다는 점입니다. 우리는 단지 값을 빌리는 것이 아니라, 그것을 파괴(destruct)하려고 합니다. 대신 그들은 "소유 참조"라고 불리는 것에 접근할 수 있게 해 줄 &own에 대한 작업을 알려주었습니다.
Daniel Henry-Mantilla는 stackbox 크레이트의 저자이자 표준 라이브러리의 pin! 매크로 뒤에 있는 수명 확장 시스템의 주요 기여자입니다. 그는 예전에 &own에 대한 매우 유익한 글을 공유했습니다. 아이디어의 핵심은 "데이터가 구체적으로 어디에 저장되어 있는가?"라는 개념과 "데이터의 논리적 소유자는 누구인가?"라는 개념을 분리해야 한다는 것입니다. 그 결과, 단지 일시적인(unique) 접근만을 제공하는 참조가 아니라, 영구적인(unique) 접근을 취해 파기 책임까지 질 수 있는 참조가 생깁니다. 그의 글에서 다음과 같은 표를 제공합니다.
T에 대한 의미 | 백킹 할당에 대해 | |
|---|---|---|
&T | 공유 접근 | 빌림 |
&mut T | 배타적 접근 | 빌림 |
&own T | 소유 접근(파기 책임) | 빌림 |
이를 이 글에 적용해 보면, 타입에 일시적으로 배타적 접근만 부여하고 실제로 타입을 드롭할 수는 없는 &mut self를 받던 Relocate 트레이트를 다음과 같이 바꿀 수 있습니다.
trait Relocate {
fn relocate(&mut self) -> super Self;
}
이제 타입에 영구적인 배타적 접근을 부여하고 실제로 타입을 드롭할 수 있는 &own을 받도록 바꿉니다.
trait Relocate {
fn relocate(&own self) -> super Self;
}
수정 2024-07-08: 이 예제는 나중에 추가되었습니다. &own이 무엇을 해결하는지 설명하기 위해, 지난 글의 Relocate 예제 구현을 살펴보겠습니다. 그곳에서는 다음과 같이 말했습니다.
여기에는 다소 위험한 가정을 하나 하고 있습니다. 바로 self로부터 빌림이 걸려 있어 움직일 수 없는 상황에 부딪히지 않고, self에서 소유 데이터를 꺼낼 수 있어야 한다는 점입니다.
struct Cat {
data: String,
name: &'self str,
}
impl Cat {
fn new(data: String) -> super Self { ... }
}
impl Relocate for Cat {
fn relocate(&mut self) -> super Self {
let mut data = String::new(); // ← 더미 타입, 할당 없음
mem::swap(&mut self.data, &mut data); // ← 소유 데이터 꺼내기
super let cat = Cat { data }; // ← 새 인스턴스 구성
cat.name = cat.data.split(' ').next().unwrap(); // ← 자기 참조 만들기
cat // ← 새 인스턴스 반환
}
}
&own이 제공하는 것은 여기의 의미론을 올바르게 인코딩하는 방법입니다. 타입 자체는 이동되지 않으므로 값 자체를 이동(by-value)할 수는 없습니다. 하지만 논리적으로는 값의 유일한 소유권을 주장해 타입을 파괴하고 개별 필드를 이동하고 싶습니다. 이것은 by-value로 Box를 이동하는 방식과도 어느 정도 유사하지만, 할당이 힙이 아닌 어디에나 있을 수 있다는 점이 다릅니다. 이를 통해 위의 다소 위험한 mem::swap 코드를 보다 일반적인 구조 분해 + 초기화 형태로 다시 쓸 수 있습니다.
struct Cat {
data: String,
name: &'self str,
}
impl Cat {
fn new(data: String) -> super Self { ... }
}
impl Relocate for Cat {
fn relocate(&mut self) -> super Self {
let Self { data, .. } = self; // ← `self` 파괴(destruct)
super let cat = Cat { data }; // ← 새 인스턴스 구성
cat.name = cat.data.split(' ').next().unwrap(); // ← 자기 참조 만들기
cat // ← 새 인스턴스 반환
}
}
이 트레이트는 고정된 메모리 위치에 타입을 구성해야 하므로 어떤 형태로든 -> super Self 문법이 필요합니다. 어쨌든 이곳이 그 문법이 여전히 필요한 유일한 장소가 될 것입니다. &own의 최신 진행 상황을 따라가고 싶다면 관련 Rust 이슈(이 이슈도 Niko가 올렸습니다)를 참고하세요.
이제 이를 염두에 두고 지난 글의 동기 예제를 다시 한 번 손볼 수 있습니다. 모두의 기억을 새로고침하기 위해, 이것이 우리가 작성하고자 하는 고수준 async/.await 기반 Rust 코드입니다.
async fn give_pats() {
let data = "chashu tuna".to_string();
let name = data.split(' ').take().unwrap();
pat_cat(&name).await;
println!("patted {name}");
}
async fn main() {
give_pats().await;
}
그리고 이 글에서의 업데이트를 사용하면 이를 디슈가링할 수 있습니다. 이번에는 경로 기반 수명과 컴파일러의 자동 참조 안정성 덕분에 Move나 제자리 생성에 대한 어떤 참조도 필요하지 않습니다.
enum GivePatsState {
Created,
Suspend1,
Complete,
}
struct GivePatsFuture {
resume_from: GivePatsState,
data: Option<String>,
name: Option<&'self.data str>, // ← 여기서 `'self.data` 수명에 주목
}
impl GivePatsFuture { // ← 제자리 생성 불필요
fn new() -> Self {
Self {
resume_from: GivePatsState::Created,
data: None,
name: None
}
}
}
impl Future for GivePatsFuture {
type Output = ();
fn poll(&mut self, cx: &mut Context<'_>) // ← `Pin` 불필요
-> Poll<Self::Output> { ... }
}
정의가 이전보다 훨씬 단순해졌습니다. 실제 호출의 디슈가링도 더 간단해졌는데, 제자리 생성을 보장하기 위한 중간 IntoFuture 생성자가 더 이상 필요 없습니다.
let into_future = GivePatsFuture::new();
let mut future = into_future.into_future(); // ← `pin!` 불필요
loop {
match future.poll(&mut current_context) {
Poll::Ready(ready) => break ready,
Poll::Pending => yield Poll::Pending,
}
}
필요한 것은 이동 시 self-포인터의 주소를 컴파일러가 갱신해 주는 것뿐입니다. 이는 값이 이동될 때마다 약간의 추가 코드 생성을 요구합니다. 즉, 단순한 비트 단위 복사 대신 포인터 값도 함께 갱신해야 하죠. 하지만 구현은 충분히 가능해 보이고, 실제 성능도 매우 좋을 것이며, 가장 중요한 점은 사용자가 거의 신경 쓸 일이 없다는 겁니다. &'self.field를 쓰면 언제나 그냥 동작합니다.
그렇다고 이동 불가 타입 아이디어를 완전히 배제하고 싶지는 않습니다. 이동할 수 없는 타입이 가진 이점은 분명 있습니다. 특히 이동 불가성을 요구하는 FFI 구조체를 다룰 때, 또는 스택 기반 데이터를 향한 자기 참조를 많이 쓰고 포인터 갱신 비용이 너무 비싸질 수 있는 고성능 자료구조에서는 더욱 그렇습니다. 이런 사용 사례는 분명 존재하지만 꽤 틈새일 것입니다. 예를 들어 Rust for Linux는 침습적 연결 리스트에 이동 불가 타입을 사용하고 있고, 아마 그런 구조는 실제로 동작하려면 어떤 형태로든 이동 불가성이 필요할 것입니다.
하지만 컴파일러가 자기 참조를 제공하기 위해 이동 불가 타입을 요구하지 않게 된다면, 이동 불가 타입은 구조적으로 반드시 필요한 요소에서 최적화에 가까운 것으로 성격이 바뀝니다. 여전히 추가할 가치가 있을 가능성이 큽니다. 확실히 더 효율적이니까요. 그러나 제대로 한다면 이동 불가 타입의 도입은 하위 호환이 가능할 것이며, 나중에 최적화로 추가하는 것도 가능합니다.
async {}가 impl Future를 반환해야 하는지, 아니면 impl IntoFuture를 반환해야 하는지에 대해선, 답은 확실히 impl IntoFuture여야 한다고 생각합니다. 2024 에디션에서 우리는 구간(range) 문법(0..12)의 반환을 Iterator에서 IntoIterator로 바꿉니다. 이는 Swift의 동작과도 맞습니다. Swift에서 0..12는 IteratorProtocol이 아니라 Sequence를 반환합니다. 이는 async {}와 gen {}도 각자의 트레이트가 아니라 impl Into* 트레이트를 반환해야 할 강한 신호라고 봅니다.
제가 쓴 글이 논의로 이어지고, 그 과정에서 다른 관련 작업들을 접하게 되는 상황을 좋아합니다. 자기 참조 타입을 가능하게 하려면, 이제는 이동 불가 타입보다 언어 차원의 포인터 자동 갱신을 내장하는 쪽에 분명히 더 마음이 갑니다(수정: 너무 성급했을지도). 다만 이동 불가 타입이 필요하다면, 지난 글이 그 방향으로 가는 일관되고 사용자 친화적인 설계를 제공한다고 생각합니다.
완전한 자기 참조 타입 스토리를 구현하려면 꽤 많은 의존성이 필요합니다. 다행히 기능을 하나씩 도입해 가며 점차 더 표현력 있는 형태의 자기 참조 타입을 활성화할 수 있습니다. 중요도 측면에서 보면, 어떤 형태로든 'unsafe가 좋은 출발점으로 보입니다. 그다음이 장소 기반 수명입니다. 뷰 타입은 유용하지만, Option을 이용한 단계적 초기화로 우회할 수 있으므로 반드시 핵심 경로에 있지는 않습니다. 아래는 모든 기능과 그 의존성을 나타낸 그래프입니다.

이렇게 기능을 쪼개 보니, 오히려 전반적으로 충분히 해 볼 만하다는 인식이 강화되었습니다. 'unsafe는 그리 멀지 않아 보입니다. 그리고 Niko도 경로 기반 수명과 뷰 타입에 대해 상당히 진지하게 이야기했습니다. 이들이 실제로 얼마나 빨리 개발될지는 두고 봐야겠지만, 이렇게 정리해 두고 보니 다소 낙관적으로 느껴집니다!
모든 참고 문헌 보기