Rust에 다양한 종류의 소멸(파괴) 메커니즘을 도입하는 ‘통제된 파괴(Controlled destruction)’ 제안. 기본 트레잇 경계 Forget과 Move/Destruct/Pointee 계층을 통해 async drop, 값 누수 방지(Forget 금지), 인자를 받는 소멸자, async 범위 기반 병렬 작업을 가능하게 하고, 더 약한 디폴트에 선택적으로 옵트인하는 방식으로 통합·검증하는 방법을 설명한다.
이 글은 Rust를 확장해 여러 형태의 소멸자(destructor)를 지원하는 제안을 소개합니다. 이를 통해 async drop을 지원할 수 있을 뿐 아니라 값 “망각(누수)”을 방지하여 rayon/libstd 스타일의 병렬 실행이 가능한 async 범위 기반 작업(async scoped tasks)을 가능하게 합니다. 또한 “소멸자”가 인자를 요구하는 타입도 가질 수 있게 됩니다. 이 제안은 “must move”의 진화형으로, 저는 이를 “통제된 파괴(controlled destruction)”라고 부르겠습니다. 이 제안은 Rust가 시스템 프로그래밍에서 중요한 패턴들에 대한 안전한 버전을 제공한다는 목표를 완수하는 데 필요하다고 생각합니다. 따라서 async Rust와 sync Rust가 대체로 같은 방식으로 동작하는 “async 드림”을 완성하는 데도 필수적입니다.
이렇게 좋은 것이 공짜로 오지는 않습니다. 이 제안의 큰 함정은 Rust의 타입에 더 많은 “코어 분기(core split)”를 도입한다는 점입니다. 저는 이 분기가 잘 정당화되고 합리적이라고 믿습니다 — 다시 말해, 이는 ‘내재적 복잡성’을 반영합니다 — 그럼에도 불구하고 신중하게 고민할 필요가 있습니다.
요약하자면 다음과 같습니다:
새로운 “디폴트 트레잇 경계” Forget과 연관된 트레잇 계층을 도입합니다:
trait Forget: Drop — 잊어도(누수시켜도) 되는 값trait Destruct: Move — 소멸자를 가진 값trait Move: Pointee — 이동 가능한 값trait Pointee — 모든 값을 나타내는 기반 트레잇RFC #3729 (Hierarchy of Sized Traits)에서 제안된 크기 정보에 대한 “더 약한 디폴트에 옵트인” 스킴을 사용합니다
fn foo<T>(t: T)는 기본적으로 “망각/소멸/이동 가능한 T”를 의미합니다.fn foo<T: Destruct>(t: T)는 “소멸 가능하지만 반드시 망각 가능하진 않은 T”를 의미합니다.fn foo<T: Move>(t: T)는 “이동 가능하지만 반드시 망각 가능하진 않은 T”를 의미합니다.새 트레잇을 통합하고 강제합니다:
std::mem::forget의 경계는 이미 Forget를 요구하므로 좋습니다.Destruct를 구현해야 함을 강제할 수 있습니다; 사실 const fn에서 const Destruct 경계를 강제할 때 이미 이걸 하고 있습니다.Move 경계를 요구하도록 확장할 수 있습니다.클로저의 트레잇 경계를 조정합니다(다행히 꽤 깔끔하게 풀립니다)
몇 해 전 우루과이에서 열린 Rust LATAM에서 한 발표1에서, 저는 이렇게 말했습니다:

Rust는 현재 프로그램의 일부들이 서로 간섭하지 않도록 막는 데는 꽤 능하지만, 정리(cleanup)가 이루어지도록 보장하는 데는 그만큼 능하지 못합니다2. 물론 소멸자는 있지만, 두 가지 중대한 한계가 있습니다:
fn drop(&mut self)를 가져야 하는데, 이는 항상 충분하지 않습니다.위의 동기는 다소 추상적이니, 이 한계와 직접 연결되는 몇 가지 구체적 예를 들어보겠습니다:
async 또는 const drop을 가능하게 하는 것 — 둘 다 서로 다른 drop 시그니처를 요구합니다.이 글의 목표는 위 문제들을 모두 해결할 수 있고, 오늘의 Rust와도 하위 호환되는 접근을 개략적으로 제시하는 것입니다.
핵심 문제는 오늘날 Rust가 모든 Sized 값이 이동, 드롭, 망각될 수 있다고 가정한다는 점입니다:
// `T`가 `Sized`라는 것 외에는 아무 것도 모르는 상황에서,
// 우리는 다음을 할 수 있습니다...
fn demonstration<T>(a: T, b: T, c: T) {
// ...`a`를 드롭하여 소멸자를 즉시 실행.
std::mem::drop(a);
// ...`b`를 잊어 소멸자를 건너뜀
std::mem::forget(b);
// ...`c`를 `x`로 이동
let x = c;
} // ...그리고 블록을 벗어나면서 `x`는 자동으로 드롭됩니다.
제 생각에 대부분의 메서드는 “옵트인”입니다 — 호출하지 않으면 실행되지 않습니다. 하지만 소멸자는 다릅니다. 사실상 기본으로 실행되는 메서드 — 즉, forget을 호출하는 등으로 옵트아웃하지 않는 한 실행됩니다. 하지만 옵트아웃이 가능하다는 것은 소멸자가 근본적으로 일반 메서드보다 더 많은 힘을 주지는 않는다는 뜻이기도 합니다. 다만 더 인체공학적인 API를 제공할 뿐입니다.
그 함의는, 오늘날 Rust에서 소멸자가 실행될 것을 ‘보장’하는 유일한 방법은 그 값의 소유권을 유지하는 것뿐이라는 점입니다. 이는 unsafe 코드에 중요할 수 있습니다 — 예를 들어 범위 기반 스레드를 허용하는 API는, 그 병렬 스레드가 함수가 반환되기 전에 반드시 join됨을 ‘보장’해야 합니다. 이를 위한 유일한 방법은 scope에 대해 &-대여된 접근만을 주는 클로저를 사용하는 것입니다:
scope(|s| ...)
// - --- ...이것은 이 함수 본문이
// | 그것을 “forget”할 수 없음을 보장합니다.
// |
// 이 값의 타입은 `&Scope`...
API가 스코프의 소유권을 넘겨주지 않기 때문에, 스코프가 “forget”되지 않아 소멸자가 실행됨을 보장할 수 있습니다.
범위 기반 스레드 접근법은 동기 코드에서는 동작하지만, 비동기 코드에서는 동작하지 않습니다. 문제는 async 함수가 값인 future를 반환한다는 점입니다. 따라서 사용자는 다른 값과 마찬가지로 이 값을 “forget”할 수 있고, 그 결과 소멸자가 결코 실행되지 않을 수 있습니다.
살펴보면, 소멸자가 ‘보장’되는 상황은 시스템 프로그래밍에서 꽤 자주 등장합니다. futures의 범위 기반 API가 한 예고, DMA(Direct Memory Access)가 또 다른 예입니다. 많은 임베디드 장치는 비동기적으로 메모리에 쓰기를 수행하는 DMA 전송을 시작하는 모드를 갖습니다. 하지만 이 DMA가 해당 메모리가 해제되기 전에 종료됨을 보장해야 합니다. 그 메모리가 스택에 있다면, DMA를 취소하거나 DMA가 끝날 때까지 블록하는 소멸자가 필요하다는 뜻입니다.4
이 상황은 기본 Sized 경계를 재검토하는 과제와 매우 유사하며, 이 블로그 글에서 제가 개략한 동일한 기본 접근이 통할 것 같습니다.
핵심 아이디어는 간단합니다. 계층으로 배열된 “특별한” 트레잇 집합을 갖는 것입니다:
trait Forget: Destruct {} // “잊을 수 있는” 값
trait Destruct: Move {} // “소멸(dropped)” 가능한 값
trait Move: Pointee {} // “이동” 가능한 값
trait Pointee {} // 포인터로 참조 가능한 모든 값
기본적으로 제네릭 매개변수는 Forget 경계를 가지므로, fn foo<T>()는 fn foo<T: Forget>()와 같습니다. 하지만 매개변수가 더 약한 경계로 옵트인한다면 기본은 제거되므로, fn bar<T: Destruct>()는 T가 소멸 가능하지만 망각 가능하지는 않음을 의미합니다. 그리고 fn baz<T: Move>()는 T가 오직 이동만 가능함을 나타냅니다.
이 경계들이 어떻게 작동하는지 간략히 설명하겠습니다.
기본 타입 T(혹은 명시적으로 Forget을 쓰는 경우)를 주면, 함수는 오늘 가능한 모든 일을 할 수 있습니다:
fn just_forget<T: Forget>(a: T, b: T, c: T) {
// --------- 이 경계는 기본입니다
std::mem::drop(a); // OK
std::mem::forget(b); // OK
let x = c; // OK
}
T: Forget을 요구한다std::mem::forget 함수 역시 T: Forget을 요구합니다:
pub fn forget<T: Forget>(value: T) { /* magic intrinsic */ }
따라서 Destruct만 있는 경우, 함수는 drop이나 move만 할 수 있고 “forget”은 할 수 없습니다:
fn just_destruct<T: Destruct>(a: T, b: T, c: T) {
// -----------
// 이 함수는 “Destruct” 능력만을 요청합니다.
std::mem::drop(a); // OK
std::mem::forget(b); // 오류: `T: Forget` 필요
let x = c; // OK
}
Destruct를 구현해야 함을 요구한다drop 함수는 오직 T: Destruct만 요구하도록 바꿉니다:
fn drop<T: Destruct>(t: T) {}
또한 빌림 검사기를 확장하여, 값이 드롭되는(즉 스코프를 벗어나기 때문에) 경우 Destruct 경계를 요구하도록 합니다.
이는 타입이 오직 Move인 값을 가지고 있다면, 그 값을 “드롭”할 수 없음을 의미합니다:
fn just_move<T: Move>(a: T, b: T, c: T) {
// -----------
// 이 함수는 “Move” 능력만을 요청합니다.
std::mem::drop(a); // 오류: `T: Destruct` 필요
std::mem::forget(b); // 오류: `T: Forget` 필요
let x = c; // OK
} // 오류: `x`가 드롭되는데, `T: Destruct`가 아님
즉 Move 경계만 있다면, 함수에서 반환하려면 자신이 소유한 모든 것을 반드시 이동해야 합니다. 예를 들어:
fn return_ok<T: Move>(a: T) -> T {
a // OK
}
이동하지 않는 함수라면 오류가 납니다:
fn return_err<T: Move>(a: T) -> T {
} // 오류: `a`는 `Destruct`를 구현하지 않음
패닉과 맞닥뜨릴 때 이게 엄청나게 성가실 수 있다는 점은 언급할 가치가 있습니다:
fn return_err<T: Move>(a: T) -> T {
// 오류: 패닉이 발생하면 `a`가 드롭되지만, `T`는 `Destruct`가 아님
forbid_env_var();
a
}
fn forbid_env_var() {
if std::env::var("BAD").is_ok() {
panic!("Uh oh: BAD cannot be set");
}
}
저는 괜찮다고 보지만, 이는 패닉을 정적으로 배제하는 더 나은 방법에 압력을 가하게 될 것입니다.
Destruct의 const(그리고 나중엔 async) 변형사실 우리는 이미 const 함수에서 이와 매우 비슷한 소멸 검사(destruct check)를 하고 있습니다. 지금은 const fn에서 값을 드롭하려 하면 오류가 납니다:
const fn test<T>(t: T) {
} // 오류!
컴파일하면 다음과 같은 오류가 납니다:
error[E0493]: destructor of `T` cannot be evaluated at compile-time
--> src/lib.rs:1:18
|
1 | const fn test<T>(t: T) { }
| ^ - value is dropped here
| |
| the destructor for this type cannot be evaluated in constant functions
이 검사는 현재는 빌림 검사기에서 이루어지지 않지만, 그럴 수 있습니다.
Move를 구현해야 함을 요구한다마지막으로, “이동”되는 값이 Move를 구현해야 한다는 요구입니다:
fn return_err<T: Pointee>(a: T) -> T {
a // 오류: `a`는 `Move`를 구현하지 않음
}
!Move 타입이 있으면 pin이 필요 없다고 생각할 수도 있지만, 그렇지 않습니다. ‘핀(pin)된’ 값은 ‘다시는 이동할 수 없는’ 값인 반면, Move가 아닌 값은 애초에 — 한 번 어떤 장소에 저장되고 나면 — 이동될 수 없는 값입니다.
이 부분이 타당한지는 확신이 없습니다. 우선은 모든 타입이 Move, Destruct, 혹은 (기본) Forget 중 하나라고 가정하는 것부터 시작할 수도 있습니다.
또한 명시적으로 망각 가능성에서 “옵트아웃”할 수 있어야 합니다. 예를 들어 다음과 같이요:
struct MyType {}
impl Destruct for MyType {}
이렇게 하면 물론, 이 타입을 받을 수 있는 제네릭이 제한됩니다.
이런 “디폴트 경계” 제안에서 까다로운 부분은 언제나 연관 타입 경계입니다. 하위 호환을 위해 Forget을 기본으로 해야 하지만, 오늘날 실제로 존재하는 많은 연관 타입들은 정말로 Forget을 ‘요구해서는 안’ 됩니다. 예컨대 Add 같은 트레잇은 반환 타입에 대해 ‘실은’ Move만 요구해야 합니다:
trait Add<Rhs = Self> {
type Output /* : Move */;
}
저는 기본적으로 그리 걱정하지 않습니다. 시간이 지나면서나 에디션을 통해 이러한 경계를 약화시킬 수 있을지도 모릅니다. 혹은 다음과 같은 에디션 전용 “별칭(alias)”을 도입할 수도 있습니다:
trait Add2025<Rhs = Self> {
type Output: Move;
}
여기서 Add2025는 Add를 구현하는 모든 것에 대해 구현됩니다.
정확히 어떻게 관리할지는 확실치 않지만, 해결책을 찾을 수 있을 겁니다 — 그리고 그동안 정말로 망각 가능해서는 안 되는 타입의 대부분은 실은 그리 많은 곳을 통과하지 않는 ‘가드’ 타입입니다.
연관 타입 경계를 ‘정말로 중요하게’ 약화시켜야 하는 한 곳은 클로저입니다 — 그리고 다행히, 이는 우리의 “클로저 트레잇 경계” 문법 덕분에 가능한 영역입니다. 이 주제로 글을 쓴 기억이 있는데 찾지 못하겠네요. 짧게 말하면, 오늘날 F: Fn()이라고 쓰면 그 클로저는 반드시 ()를 반환해야 합니다. F: Fn() -> T라고 쓰면, 이 타입 T는 다른 곳에서 선언되어야 하며, 따라서 Fn 트레잇의 연관 타입과는 독립적으로 T는 기본 Forget 경계를 갖습니다. 즉 안정된 Rust에서 Fn의 연관 타입은 독립적으로 이름 붙일 수 없으므로, 그 경계를 바꿀 수 있으며, 다음과 같은 코드는 변함없이 동작할 것입니다:
fn foo<T, F>()
where
F: Fn() -> T,
// - `T: Forget`은 여전히 기본으로 성립
{}
최근 저는 “부분구조적(substructural) 타입 시스템”에 대한 이 internals 스레드를 보게 되었습니다. 아마 매우 유사한 능력을 가질 것입니다. 솔직히 말해 아직 읽고 소화할 시간이 없었습니다! 하지만 이 글은 95% 정도 완료되어 있었기에 먼저 올리고 나서 비교해 보려고 합니다.
Move에서 옵트아웃(예: 오직 Pointee만 허용)한다는 건 무슨 뜻이죠?제가 묘사한 시스템은 ‘이동 불가’ 타입(즉 모든 것에서 옵트아웃하고 오직 Pointee만 허용하는 구조체)을 허용하긴 합니다. 하지만 그런 구조체는 사실상 정적 메모리 위치에만 저장할 수 있습니다. 스택에는 둘 수 없습니다. 스택은 결국 팝되기 때문입니다. 그리고 이리저리 옮길 수도 없습니다. 왜냐하면 그건 이동 불가능하니까요.
이는 “비디오 RAM”처럼 메모리의 특정 위치에만 존재하고 다른 곳에는 존재할 수 없는 것을 모델링하는 데 유용할 수 있어 보입니다. 하지만 널리 필요한 기능은 아닙니다.
저는 다음과 같은 모습을 상상합니다:
struct Transaction {
data: Vec<u8>
}
/// 소멸에서 옵트아웃
impl Move for Transaction { }
impl Transaction {
// 이것은 사실상 “소멸자”입니다
pub fn complete(
self,
connection: Connection,
) {
let Transaction { data } = self;
}
}
이 설정에서, Transaction을 소유한 모든 함수는 결국 transaction.complete()을 호출해야 합니다. 이 타입의 값은 드롭될 수 없으므로, 반드시 이동되어야 하기 때문입니다.
이 설정은 제 머릿속에서 async drop을 막아 온 핵심 문제를 공략합니다. 즉, “async drop”인 타입은 “sync drop”을 구현할 필요가 없다는 점입니다. 이는 타입 시스템이 그 타입이 동기 코드에서 드롭되는 것을 막을 수 있게 해주며, 그 결과 오직 async drop에서만 드롭될 수 있음을 의미합니다. 다만 여기에는 여전히 많은 설계 작업이 남아 있습니다.
Drop이 아니라 Destruct인가요?이는 const generics 작업에서 비롯되었습니다. 저도 아주 마음에 들진 않습니다. 하지만 논리는 있습니다. 현재 구조체나 다른 값을 드롭할 때 실제로는 여러 동작이 일어납니다. 그 중 하나만이 Drop impl을 실행하는 것입니다 — (예를 들어) 구조체의 모든 필드를 재귀적으로 드롭하는 것도 포함됩니다. “destruct”는 이 전체 시퀀스를 가리키는 개념입니다.
사실… 아주 어렵지는 않을 거라고 봅니다. 어느 정도 생각해 봤는데 모든 변경이 꽤 직관적입니다. 저는 이에 대한 lang-team experiment를 기꺼이 지원하고 싶습니다.
소멸자와 누수(leak) 등의 주제는 대략 Rust 1.0 즈음으로 거슬러 올라갑니다. 그때 우리는 스레드에 대한 우리의 추상이 순환 참조 카운팅 박스와 결합되면 불완전하다는 것을 발견했죠. 그 전에는 소멸자가 “옵트아웃 메서드”라는 점을 충분히 내면화하지 못했었습니다. 그 당시 제가 쓴 이 블로그 글을 읽어보세요. 당시 주요 아이디어는 어떤 ?Leak 경계를 두는 것이었고, 참조 개념에 묶여 있었습니다(그래서 모든 'static 데이터는 “누수 가능(leakable)”하다고 가정되어 Rc에 넣을 수 있도록). 저는… 대체로 그때 옳은 결정을 내렸다고 봅니다. 생태계 대부분이 상호 운용 가능하고 Rc가 static 경계를 요구하지 않는 건 좋은 일이라 생각합니다. 그리고 분명 우리는 최소한의 혼란으로 1.0에 도달한 것도 좋았죠. 어쨌든, 저는 그때 논의되던 설계들보다 지금 이 설계를 더 선호합니다. 부분적으로는 다양한 형태의 소멸자, 다수의 인자를 받는 소멸자 필요성까지 함께 다루기 때문인데, 당시에는 이 부분을 고려하지 않았기 때문입니다.
저는 오늘날의 ? 설계보다 ‘원하는 경계를 명시하는’ 편이 본질적으로 더 낫다고 생각합니다. 이해하기 쉽고, ? 설계로는 불가능한 방식으로 중간에 트레잇을 하위 호환으로 추가할 수 있기 때문입니다.
다만 T: Move가 T: Destruct가 성립하지 않음을 의미하는 점은 미묘하다고 봅니다. 이런 트레잇에 T: @Move 같은 기호나 관례를 도입해야 할지 고민됩니다. 잘 모르겠네요! 고려해 볼 문제입니다.
그 컨퍼런스는 정말 훌륭했습니다. 흥미롭게도, 제 발표 중에서도 제가 가장 좋아하는 편이지만 이상하게 이 자료를 다시 쓰는 일은 드뭅니다. 바꿔야겠네요.↩︎
학계에서는 “안전(safety)”과 “활성(liveness) 속성”을 구분하는데, 안전은 “나쁜 일이 일어나지 않는다”, 활성은 “좋은 일이 결국 일어난다”를 의미합니다. 다시 말해 Rust의 타입 시스템은 많은 안전 속성에는 도움이 되지만, 활성 속성에는 고전합니다.↩︎
음, 출처 필요. 사실임은 알지만 관련 WebAssembly 이슈를 찾지 못했습니다. 인터넷, 도와줘요
사실 DMA 문제는 범위 기반 스레드와 동일합니다. 생각해 보면, 메모리에 쓰는 임베디드 장치는 기본적으로 메모리에 쓰는 병렬 스레드와 같습니다.↩︎