Rust에 'must move' 타입을 도입하는 아이디어를 제시하고, ?Drop 표기와 예제, 제어 흐름·패닉·trait object와의 상호작용, 그리고 async drop과 구조적 동시성 등 동기가 되는 사용처를 논한다.
Rust에는 나쁜 일을 하지 못하도록 막아 주는 장치가 아주 많다. 하지만 지금으로서는 당신이 좋은 일을 하도록 강제하는 장치는 전혀 없다1. 요즘 나는 언어에 “must move” 타입을 추가한다는 게 무엇을 의미하는지 곰곰이 생각해 보고 있다. 이 아이디어는 오랫동안 저항해 온 주제인데, 근본적으로 복잡성을 키우기 때문이다. 하지만 최근에는 이것이 해결에 도움을 줄 수 있는 문제들이 점점 더 많이 보이기 시작했고, 그래서 정말 좋은 아이디어인지 더 잘 판단할 수 있도록 어떻게 보일지 구상해 보고 싶었다.
‘must move’ 타입이라는 용어는 표준이 아니다. 내가 만든 말이다. PL(프로그래밍 언어) 분야에서 더 일반적인 이름은 “선형(linear)” 타입으로, 정확히 한 번만 사용되어야 하는 값을 뜻한다. must move 타입 T의 아이디어는 다음과 같다. 어떤 함수 f가 타입 T의 값 t를 가지고 있다면, f는 반환하기 전에(아래에서 논할 패닉은 예외로 하고) 그 값을 반드시(move) 이동해야 한다. t를 이동한다는 것은 t의 소유권을 가져가는 다른 함수를 호출하거나, 그것을 반환하거나, — 나중에 보겠지만 — 패턴 매칭으로 구조 분해하는 것을 의미할 수 있다.
다음은 값 t를 이동(move) 하는 함수의 예시들이다. 반환할 수도 있고…
fn return_it<T>(t: T) {
t
}
…소유권을 가져가는 함수를 호출할 수도 있고…
fn send_it<T>(t: T) {
channel.send(t); // `t`의 소유권을 넘김
}
…혹은 소유권을 가져가는 생성자 함수를 호출할 수도 있다(대개 결과 역시 “재귀적으로” 이동해야 함)…
fn return_opt<T>(t: T) -> Option<T> {
Some(t) // t를 옵션으로 이동
}
Rust의 소유권과 빌림이 일종의 “선형 타입”이라고 들었을지 모른다. 사실이 아니다. Rust가 가진 것은 _아핀(affine) 타입_으로, 값이 기껏해야 한 번 이동될 수 있음을 의미한다. 하지만 값을 반드시 이동하도록 강제하는 것은 없다. 예를 들어, 오늘날 Rust에서 다음과 같은 consume 함수를 작성할 수 있다:
fn consume<T>(t: T) {
/* 보세요, 아무것도 안 해요 */
}
이 함수는 (아래에 예외가 있긴 하지만) 거의 모든 타입 T의 값 t를 받아서…아무 것도 하지 않는다. 이것은 선형 타입에서는 불가능하다. 만약 T가 선형 이라면, 우리는 t로 _무언가_를 해야 한다 — 예컨대 어딘가로 이동해야 한다. 그래서 나는 선형 타입을 _must move_라고 부른다.
“잠깐만요!”, 라고 생각할 수 있다. “consume은 사실 t에 대해 아무 것도 안 하는 것이 아니에요. t를 drop해서 소멸자를 실행하잖아요!” 좋은 지적이다. 맞다. 하지만 consume이 소멸자를 실행하도록 강제되지는 않는다. 언제든 forget을 써서 피할 수 있다2:
fn consume<T>(t: T) {
std::mem::forget(t();
}
만약 값을 “잊는(forget)” 것이 불가능했다면, 소멸자는 Rust가 선형 시스템을 가졌다는 뜻이 되겠지만, 그마저도 기술적인 의미에 그친다. 특히 소멸자는 요구되는 동작이긴 하지만 형태가 제한되어 있다 — 예컨대 인자를 받을 수 없다. async도 될 수 없다.
Sized는 어떨까?consume 타입에 관해 언급할 다른 세부사항이 하나 더 있다. 내가 fn consume<T>(t: T)라고 쓸 때, 이것은 사실 “Sized인 모든 타입 T”라는 의미의 축약형 이다. 즉, 완전히 풀어 쓴 “값으로 아무 것도 하지 않는” 함수는 다음과 같다:
fn consume<T: Sized>(t: T) {
std::mem::forget(t();
}
이 기본 Sized 바운드를 원하지 않는다면, T: ?Sized라고 쓴다. 앞의 ?는 “Sized일 수도 있다”는 뜻 — 즉 이제 T는 크기가 정해진(sized, 예: u32) 타입일 수도, 크기가 정해지지 않은(unsized, 예: [u32]) 타입일 수도 있다.
이 점이 중요하다: T: Foo 같은 where-절은 T가 될 수 있는 타입의 집합을 좁힌다. 이제 T는 Foo를 구현한 타입이어야 하기 때문이다. 반면 “maybe” where-절인 T: ?Sized(여기서는 다른 트레이트는 허용하지 않는다)는 기본 바운드를 제거함으로써 T가 될 수 있는 타입의 집합을 넓힌다.
새로운 종류의 바운드, 예컨대 T: MustMove 같은 것으로 “must move” 타입을 인코딩할 수 있다고 상상할 수 있다. 하지만 그것은 사실 방향이 반대다. 문제는 “must move” 타입이 실제로는 보통 타입의 상위 집합이라는 점이다 — 결국 보통 타입을 가지고 있다면, 그것을 항상 이동하는 함수를 작성해도 괜찮다. 하지만 보통 타입은 드롭 하거나 잊어버려도 된다. 반대로, “must move” 타입에서는 유일한 선택지가 이동뿐이다. 이는 우리가 필요한 것이 일반적인 바운드가 아니라 ? 바운드임을 암시 한다.
내가 제안하는 표기는 ?Drop이다. 기본적으로 모든 타입 매개변수 D는 드롭 가능(droppable) 하다고 가정한다. 즉, 언제든 원한다면 그 값을 드롭할 수 있다. 하지만 M: ?Drop 매개변수는 반드시 드롭 가능하다고는 할 수 없다. 타입 M의 값을 어딘가로 이동시키도록 보장해야 한다.
몇 가지 예시를 보자. 먼저, 인자를 그대로 반환하는 identity 함수는 ?Drop과 함께 선언될 수 있다:
fn identity<M: ?Drop>(m: M) -> M {
m // OK — `m`을 호출자에게 이동
}
하지만 consume 함수는 그럴 수 없다:
fn consume<M: ?Drop>(m: M) -> M {
// 오류: `M`이 이동되지 않았습니다.
}
mem::forget을 호출하는 consume 버전은 건전한 것처럼 보일 수 있다 — 어차피 forget은 다음처럼 선언되어 있으니 말이다
fn forget<T>(t: T) {
/* 드롭을 피하기 위한 컴파일러 매직 */
}
그렇다면 consume이 forget(m)을 호출하면, 그것도 이동으로 간주되지 않을까? 답은 예, 그렇다. 하지만 그래도 여전히 오류가 발생한다. 그 이유는 forget이 ?Drop으로 선언되지 않았고, 따라서 묵시적으로 T: Drop where-절이 존재하기 때문이다:
fn consume<M: ?Drop>(m: M) -> M {
forget(m); // 오류: `forget`은 `M: Drop`을 요구하지만, 여기서는 보장되지 않습니다.
}
?Drop으로 선언하기이 체계에서, 당신이 선언하는 모든 struct와 타입은 기본적으로 드롭 가능하다. Drop을 명시적으로 구현하지 않으면, 컴파일러가 필드를 재귀적으로 드롭하는 자동 Drop impl을 추가해 준다. 하지만 부정 impl(negative impl)을 사용해 타입을 명시적으로 ?Drop으로 선언할 수 있다:
pub struct Guard {
value: u32
}
impl !Drop for Guard { }
이렇게 하면 해당 타입은 “must move”가 되고, 타입 Guard 값을 가진 어떤 함수든 그것을 어딘가로 이동시켜야 한다. 그렇다면 어떻게든 종료는 해야 할 텐데 — 그 답은 값을 패턴으로 언패킹하는 것도 “이동”의 한 형태라는 것이다. 예를 들어, Guard는 log 메서드를 선언할 수 있다:
impl Guard {
pub fn log(self, message: &str) {
let Guard { value } = self; // "self"를 이동
println!(“{value} = {message}”);
}
}
이는 프라이버시와도 잘 맞물린다. 타입에 비공개(private) 필드가 있다면, 그 모듈 내부의 함수만이 그것을 구조 분해할 수 있고, 다른 모든 이들은(결국에는) 당신의 모듈 내의 어떤 함수를 호출함으로써 이동 의무를 이행해야 한다.
Must move 값은 ? 같은 제어 흐름과 상호작용한다. 이전 섹션의 Guard 타입을 생각해 보고, 다음과 같은 함수가 있다고 상상해 보자…
fn execute(t: Guard) -> Result<(), std::io::Error> {
let s: String = read_file(“message.txt”)?; // 오류: 에러 시 `t`가 이동되지 않음
t.log(&s);
Ok(())
}
이 코드는 컴파일되지 않는다. 문제는 read_file의 ?가 Err 결과로 반환될 수 있다는 점인데, 그 경우 t.log 호출이 실행되지 않기 때문이다! 이는 좋은 오류다. Guard에 대한 log 호출이 반드시 실행되도록 보장하는 데 도움이 되기 때문이다. 하지만 다른 것들과도 상호작용할 수 있음을 상상할 수 있다. 이 오류를 고치려면 다음처럼 하면 된다…
fn execute(t: Guard) -> Result<(), std::io::Error> {
match read_file(“message.txt”) {
Ok(s) => {
t.log(&s);
Ok(())
}
Err(e) => {
t.log(“error”); // 이제 `t`가 이동됨
Err(e)
}
}
}
물론, t 값을 호출자에게 되돌려 보내서 그들의 문제가 되게 할 수도 있다.
Option과 Result 같은 타입을 이야기하다 보면 — 타입 매개변수가 “must move”일 때에만 조건부로 must move가 되는 타입이 필요하다는 것이 분명해진다. 이는 아주 쉽게 할 수 있다:
enum Option<T: ?Drop> {
Some(T),
None,
}
Option의 일부 메서드는 아무 문제 없이 동작한다:
impl<T: ?Drop> Option<T> {
pub fn map<U: ?Drop>(self, op: impl FnOnce(T) -> U) -> Option<U> {
match self {
Some(t) => Some(op(t)),
None => None,
}
}
}
다른 메서드들은 unwrap_or처럼 Drop 바운드를 요구할 것이다:
impl<T: ?Drop> Option<T> {
pub fn unwrap_or(self, default:T) -> T
where
T: Drop,
{
match self {
// OK
None => default,
// `T: Drop` 바운드가 없으면 여기서 `default`를 드롭할 수 없다.
Some(v) => v,
}
}
}
매우 흥미로운 질문 하나는 패닉의 경우 어떻게 해야 하느냐다. 까다롭다! 보통 panic은 모든 스택 프레임을 언와인드(unwind)하면서 소멸자를 실행한다. 하지만 소멸자가 없는 ?Drop 타입의 경우에는 어떻게 해야 할까?
몇 가지 옵션이 보인다:
마지막 것이 가장 매력적이지만, 정확히 어떻게 동작할지는 100% 확신이 없다. impl !Drop으로 “must move”를 옵트인하기보다 impl MustMove 같은 것을 두고, 패닉 시 호출되는 메서드를 제공하는 식이 될 수도 있다(이 메서드는 물론 abort를 선택할 수도 있다). 폴백 아이디어는 ? 연산자 같은 취소(cancellation)나 다른 제어 흐름의 드롭을 허용하는 데에도 쓰일 수 있을지 모른다(다만 그런 경우를 허용하지 않는 타입도 분명히 필요하다고 본다).
dyn은 어떻게 할까? 내 생각에는 dyn Foo가 기본적으로 dyn Foo + Drop으로 간주되어, 타입이 드롭 가능함을 요구하는 것이 답이다. “must move”인 dyn을 만들기 위해 dyn Foo + ?Drop을 허용할 수 있다. 이게 제대로 동작하려면, dyn을 소비(consume)하는 self 메서드가 있어야 한다(다만 오늘날에는 self: Box<Self> 메서드로도 할 수 있다).
권장 관행과는 반대로, 아마도 나는 이번 글에서 must move의 메커니즘에 집중하고 동기에 대해선 많이 이야기하지 않았다. 그 이유는 아직 누구에게 이 아이디어를 설득하려는 게 아니기 때문이다. 다만 어떻게 구현할 수 있을지 개략적으로 그려 보고 싶었다. 그렇다고 해도 내가 “must move” 타입에 관심을 가지는 이유를 간단히 밝히자면 다음과 같다.
첫째, async drop: 지금으로서는 async 코드에서 await를 수행하는 소멸자를 가질 수 없다. 이는 async 코드가 sync 코드와 같은 방식으로 정리를 관리할 수 없음을 의미한다. 어떤 문제들이 생기는지는 데이터베이스 핸들 드롭에 대한 현황 스토리를 보면 감을 잡을 수 있다. async drop 자체를 추가하는 것은 그리 어렵지 않지만, 정말 어려운 것은 Sabrina Jewson의 블로그 글에서 길게 문서화된 것처럼, async drop이 있는 타입이 sync 코드에서 드롭되지 않도록 보장하는 일이다. 이는 우리가 현재 모든 타입이 드롭 가능하다고 가정하기 때문이고, 바로 그 때문에 어렵다. “async drop”을 가장 단순하게 달성하는 방법은 trait AsyncDrop { async fn async_drop(self); } 같은 트레이트를 정의하고, 그 타입을 “must move”로 만드는 것이다. 그러면 호출자에게 결국 async_drop(x).await를 호출하도록 강제할 수 있다. ?를 더 쉽게 다루기 위한 문법적 설탕이 필요할 수 있지만, 그것은 나중 문제다.
둘째, 병렬 구조적 동시성. Tyler Mandry가 아주 우아하게 정리했듯이, 병렬 스코프와 async를 섞고 싶다면 잊혀질 수 없는(forgotten) futures가 필요하다. 내가 이 문제를 생각하는 방식은 이렇다: 동기 코드에서는 스택에 지역 변수 x를 만들면, 언어가 그 소멸자가 결국 실행되리라는 보장을 해 준다(이동하지 않는 한). 하지만 async 코드에서는 그런 보장이 없다. 호출자가 전체 future를 그냥 잊어버릴 수도 있기 때문이다. “must move” 타입은(패닉을 위한 어떤 콜백과 함께) 이 문제를 해결할 수 있는 도구를 준다. future 타입을 ?Drop으로 만들어서 — 이는 완전히 폴링되어야 하는 완료형 futures를 원칙 있게 통합하는 방법이 된다.
셋째, “더 큰 의미의 라이브니스 조건”. 서두에서 언급했듯이, 오늘날 Rust의 타입 시스템은 “안전성” 속성(“나쁜 일이 일어나지 않음”)을 보장하는 데는 꽤 능하지만, 라이브니스 속성(“좋은 일이 결국 일어남”)에는 그다지 유용하지 않다. 소멸자는 가까이 가게 해 주지만, 우회될 수 있다. 그런데 나는 이런저런 곳에서 라이브니스 속성이 계속 나타나는 것을 본다. 종종 진짜로 일어나야 하는 가드나 정리(cleanup)의 형태로 말이다. 인자를 받는 소멸자를 원했던 적이 단 한 번이라도 있다면, 바로 그 경우다. 특히 unsafe 코드에서 자주 등장한다. “must move” 타입을 통해 그러한 의무를 “로그”할 수 있다는 것은 다양한 방식으로 쓰일 강력한 도구처럼 느껴진다.
이 글은 내가 “must move” 타입이라고 이름 붙인, Rust에 “진정한 선형(linear)” 타입을 도입하는 한 가지 방법을 개략적으로 제시했다. 나는 이것을 ?Drop 접근법이라고 부를 것 같은데, 기본 아이디어가 타입이 “드롭 가능”에서 “옵트아웃”할 수 있게 하여(그 경우 반드시 이동해야 함) 만드는 것이기 때문이다. 이것만이 유일한 접근법은 아니다. 이 글의 목표 중 하나는 선형 능력을 추가하는 다양한 방법에 대한 아이디어를 모으고, 서로 비교할 수 있게 하는 것이다.
또 하나, 누구나 떠올릴 “방 안의 코끼리”를 짚고 넘어가야 한다. Rust 타입 시스템은 이미 복잡하고, “must move” 타입을 추가하면 틀림없이 더 복잡해진다. 그 트레이드오프가 가치 있는지 나는 아직 확신하지 못한다. 실제로 시도해 보기 전에는 판단하기 어렵다. “must move” 타입이 가드 등 자주 추상화되지 않는 가장자리에서 주로 사용될 가능성이 크다고 생각한다. Guard 같은 구체 타입을 다룰 때는 must move 타입이 특히 복잡하게 느껴지지 않을 것이다. 단지 “아, 참고로, 이건 제대로 정리해야 해요”라고 알려 주는 유용한 린트처럼 느껴질 것이다. 하지만 고통은 일반 함수를 만들려고 할 때 — 그리고 물론 Rust 언어 자체를 더 키운다는 점에서 — 생길 것이다. ?Sized 같은 것들은, 직접 마주칠 일이 거의 없더라도, 언어를 더 복잡하게 느끼게 만든다.
한편으로, “must move” 타입은 매우 현실적인 실패 모드를 막아 준다는 점에서 확실한 가치를 더한다. 나는 Rust의 목표가 무엇보다 “생산적인 신뢰성(productive reliability)”이라고 계속 느끼고 있고, 그 강점을 더욱 강화해야 한다고 본다. 다시 말해, “must move” 타입에 대해 추론할 때 생기는 복잡성은 상당 부분 내재적 복잡성 이며, 이를 위한 새로운 도구로 언어를 확장하는 것에 대해 나는 괜찮다고 느낀다. 우리는 ? 연산자와의 상호작용에서도 같은 점을 보았다 — 에러가 발생할 때 이동과 정리를 고려해야 하는 것은 확실히 성가시지만, 또한 견고한 시스템을 구축하는 핵심 부분이기도 하며, 소멸자만으로는 항상 충분하지 않다.