러스트의 빌림 검사기를 ‘내면의 빌림 검사기’라는 4단계 로드맵으로 제안한다. Polonius, 장소 기반 라이프타임 표기, 뷰 타입, 내부 참조를 통해 러스트의 ‘정신’(변이 xor 공유)에 부합하지만 현재 규칙상 거부되는 패턴들을 자연스럽게 지원하자는 내용이다.
이 글은 내가 “내면의 빌림 검사기(the borrow checker within)”라고 부르는, 빌림 검사기 개선을 위한 4단계 로드맵을 제시한다. 이 변화들은 러스트가 스스로의 더 나은 버전이 되도록 돕는 것으로, 러스트의 정신(spirit)에는 어울리지만 그 법(law)의 문자적 규칙에는 걸리는 코드 패턴들을 가능하게 하려는 것이다. 각 항목에 대한 설계는 꽤 자신 있지만, 세부를 다듬는 일은 남아 있다. 그 작업을 진행하기에 a-mir-formality가 완벽한 장소가 될 것이라고 믿는다.
내가 빌림 검사기의 정신이라고 부를 때, 나는 러스트의 핵심 설계 정신이라고 보는 “변이 xor 공유” 규칙을 뜻한다. 기본 규칙은 이렇다 — 변수 x를 통해 어떤 값을 변경(mutating)할 때는, 변수 y를 통해 그 데이터를 읽으면 안 된다 — 이 규칙이 러스트의 메모리 안전 보장을 가능하게 하며, 더불어 “컴파일되면 잘 동작한다”는 전반적 감각에도 기여한다고 생각한다.
_변이 xor 공유_는 어떤 의미에서는 필요충분조건이 아니다. 필요하지 않다는 것은(necessary가 아니라는 것은) 데이터를 마구 공유해도(예: 자바로 쓰인 모든 프로그램) 잘 동작하는 프로그램이 많기 때문이다1. 또한 충분하지도 않다(sufficient하지 않다). 어느 정도의 공유를 반드시 요구하는 문제들이 많고 — 그래서 러스트에는 Arc<Mutex<T>>, AtomicU32, 그리고 궁극의 백도어인 unsafe 같은 “뒷문”이 존재한다.
하지만 내가 러스트를 작업하면서 가장 크게 놀랐던 점은, 일단 방법을 익히고 나면 이 변이 xor 공유 패턴이 얼마나 자주 “딱 맞는” 해법이 되는가 하는 점이었다2. 또 다른 놀라움은 시간이 지날수록 드러나는 이점이었다. 이런 스타일로 작성된 프로그램은 근본적으로 “덜 놀랍기” 때문에, 시간이 흘러도 유지보수가 더 쉽다.
그런데 오늘날 러스트에는 변이 xor 공유 패턴에 부합함에도 빌림 검사기가 거부하는 여러 패턴이 있다. 이 격차를 줄이고, 빌림 검사기의 규칙이 _변이 xor 공유_를 더 완벽히 반영하도록 돕는 것이 곧 내가 말하는 _내면의 빌림 검사기_다.
나는 대리석 속에서 천사를 보고, 그를 자유롭게 할 때까지 조각했다. — 미켈란젤로
에헴, 그렇다. 해보자.
Rust 2018은 “비-렉시컬 라이프타임”(NLL)을 도입했다 — 이 다소 난해한 이름은, 함수 내부의 제어 흐름을 훨씬 더 깊게 이해하도록 빌림 검사기를 확장한 것을 뜻한다. 이 변화는 빌림 검사기가 훨씬 더 많은 코드를 수용하게 만들었고, 러스트 사용 경험을 훨씬 “유연”하게 했다.
하지만 NLL은 한 가지 중요한 경우를 다루지 못한다3. 바로 “조건부로 참조를 반환”하는 경우다. 다음은 Remy의 Polonius 업데이트 블로그 글에서 가져온 전형적 예제다:
fn get_default<'r, K: Hash + Eq + Copy, V: Default>(
map: &'r mut HashMap<K, V>,
key: K,
) -> &'r mut V {
match map.get_mut(&key) {
Some(value) => value,
None => {
map.insert(key, V::default());
// ------ 💥 오늘날에는 에러 발생,
// 하지만 Polonius에선 허용
map.get_mut(&key).unwrap()
}
}
}
Remy의 글에는 왜 이런 일이 벌어지는지와 이를 어떻게 고칠 계획인지가 더 자세히 설명되어 있다. 대체로 정확하지만, 일정이 내가 원했던 것보다 더 늘어졌다는 점(물론)만 빼고는 그렇다. 그래도 요즘 꾸준히 진전 중이다.
다음 단계는 “place 표현식”(예: x 또는 x.y)에 기반한 명시적 라이프타임 문법을 도입하는 것이다. 이에 대해서는 내 글 라이프타임 없이 빌림 검사하기에서 썼다. 기본적으로 Polonius의 기저에 있는 정식화를 문법으로 끌어올리는 일이다.
아이디어는 이렇다. 오늘날의 추상 라이프타임 매개변수에 더해, 참조의 “라이프타임”으로 프로그램 변수나 심지어 필드를 직접 참조할 수 있게 하자는 것이다. 즉, ’x라고 쓰면 “변수 x에서 빌려온” 값을 뜻한다. ’x.y라고 쓰면 x의 필드 y에서 빌린 것을 뜻하고, 심지어 '(x.y, z)라고 쓰면 x.y 또는 z에서 빌린 것을 의미한다. 예를 들면:
struct WidgetFactory {
manufacturer: String,
model: String,
}
impl WidgetFactory {
fn new_widget(&self, name: String) -> Widget {
let name_suffix: &’name str = &name[3..];
// ——- “name”에서 빌림
let model_prefix: &’self.model str = &self.model[..2];
// —————- “self.model”에서 빌림
}
}
이렇게 하면 오늘날 우리가 기입하는 많은 라이프타임 매개변수가 불필요해진다. 예를 들어, map: &mut Hashmap<K, V> 매개변수를 받고 그 맵 내부의 참조를 반환하는 고전적인 Polonius 예제는 다음처럼 쓸 수 있다:
fn get_default<K: Hash + Eq + Copy, V: Default>(
map: &mut HashMap<K, V>,
key: K,
) -> &'map mut V {
//---- 매개변수 map에서 빌림
...
}
이 문법은 더 편리하다 — 하지만 더 큰 임팩트는 러스트를 가르치고 배우기 쉽게 만든다는 점이라고 본다. 지금 라이프타임은 난처한 위치에 있다. 왜냐하면
문법은 학습에 유용하다. 모든 것을 명시적으로 만들 수 있게 해주고, 이는 어떤 개념을 진정으로 내재화하기 위한 중요한 중간 단계다 — boats가 기억에 남는 표현으로 변증법적 라쳇(dialectical ratchet)이라고 부른 것. 개인적 경험으로도 러스트를 가르칠 때 “place 기반” 문법을 가정해 설명하면 훨씬 빠르게 이해하곤 했다.
다음 퍼즐 조각은 뷰 타입(view types)으로, 함수가 접근하는 필드를 선언하는 방법이다. WidgetFactory 같은 구조체를 생각해 보자…
struct WidgetFactory {
counter: usize,
widgets: Vec<Widget>,
}
…그리고 increment_counter 같은 헬퍼 함수를 보자…
impl WidgetFactory {
fn increment_counter(&mut self) {
self.counter += 1;
}
}
오늘날, 위젯을 순회하면서 가끔 increment_counter로 카운터를 증가시키고 싶다면, 다음과 같은 에러를 만나게 된다:
impl WidgetFactory {
fn increment_counter(&mut self) {...}
pub fn count_widgets(&mut self) {
for widget in &self.widgets {
if widget.should_be_counted() {
self.increment_counter();
// ^ 💥 `self.widgets`를 순회하는 동안
// self를 가변으로 빌릴 수 없음
}
}
}
}
문제는 빌림 검사기가 함수를 하나씩 따로 분석한다는 점이다. increment_counter가 정확히 어떤 필드를 변경하는지 알지 못한다. 그래서 보수적으로 self.widgets가 변경될 수 있다고 가정하는데, 이는 허용되지 않는다. 오늘날 여러 우회책이 있다. 예를 들면 &mut self 대신 개별 필드 참조(예: counter: &mut usize)를 받는 “자유 함수”를 쓰거나, 그 참조들을 “뷰 구조체”(예: struct WidgetFactoryView<'a> { widgets: &'a [Widget], counter: &'a mut usize })에 모으는 방식이다. 하지만 이런 방법들은 직관적이지 않고, 번거로우며, 비국소적이다(코드의 큰 부분을 바꿔야 한다).
뷰 타입은 구조체 타입을 확장한다. 단지 WidgetFactory 같은 타입만 있는 게 아니라, {counter} WidgetFactory처럼 필드의 부분집합만 포함하는 “뷰”를 가질 수 있다. 이를 이용해 increment_counter가 counter 필드만 접근한다고 선언하도록 바꿀 수 있다:
impl WidgetFactory {
fn increment_counter(&mut {counter} self) {
// -------------------
// `self: &mut {counter} WidgetFactory`와 동치
self.counter += 1;
}
}
이제 컴파일러는 count_widgets를 문제없이 컴파일할 수 있다. self.widgets를 순회하면서 self.counter를 수정하는 것이 문제가 아님을 알 수 있기 때문이다4.
빌림 검사기의 규칙이 부족한 또 다른 지점이 있다. 바로 _단계적 초기화(phased initialization)_다. 오늘날 러스트는 함수형 언어 스타일을 따라 구조체를 만들 때 모든 필드의 값을 요구한다. 대부분은 괜찮지만, 어떤 구조체들은 일부 필드를 초기화한 뒤, increment_counter 같은 헬퍼 함수를 호출해 나머지를 채우고 싶을 때가 있다. 이 시나리오에서는 막힌다. 구조체를 아직 만들지 않았기 때문에 그 헬퍼 함수가 구조체에 대한 참조를 받을 수 없기 때문이다. 우회책(자유 함수, 중간 구조체 타입)은 위와 매우 유사하다.
여기서 설명한 뷰 타입에는 제약이 있다. 타입에 필드 이름이 관여하기 때문에, 공개 인터페이스에는 어울리지 않는다. 또 실무에서 쓰기 성가실 수도 있다. 함께 묶어 다니는 필드 집합들이 생기는데, 이를 매번 수동으로 복사-붙여넣기 해야 할 수도 있기 때문이다. 모두 사실이지만, 나중에 해결할 수 있는 문제라고 본다(예: 필드 그룹에 이름을 붙이는 기능).
내 경험상 뷰 타입을 쓰고 싶은 경우는 대부분 비공개 함수다. 비공개 메서드는 보통 작은 로직을 수행하고 구조체의 내부 구조를 활용한다. 반면 공개 메서드는 더 큰 작업을 수행하고 그 내부 구조를 사용자로부터 숨기려 한다. 물론 늘 그런 것은 아니다 — 동시에 호출 가능해야 할 공개 함수가 필요할 때도 있다 — 하지만 빈도는 낮다.
또한 특히 공개 함수에 대해서는 현재 동작의 장점이 있다. 이는 전방 호환성(forward compatibility)을 지켜 준다. &mut self를 받는다는 것은(일부 필드의 부분집합이 아니라) 함수가 사용하는 필드 집합을 변경하더라도 호출자에 영향을 주지 않음을 의미한다. 이 문제는 비공개 함수에서는 고려 대상이 아니다.
오늘날 러스트는 한 필드가 다른 필드가 소유한 데이터(동일한 값의 내부)를 참조하는 구조체를 직접적으로 지원하지 않는다. 이 격차는 rental 같은 크레이트(더 이상 유지보수되지 않음)로 부분적으로 메워지거나, 더 자주 인덱스로 내부 참조를 모델링하여 해결된다. 또한 관련된(그리고 더 어려운) 문제인 불동작성(immbobile) 데이터를 위한 Pin도 있다.
나는 한동안 이 문제의 해법을 다듬어 왔다. 이 글에서 모두 설명할 수는 없지만, 내가 구상하는 바를 개략적으로 보여 주고, 자세한 내용은 후속 글에서 다루겠다(성립함을 확신할 만큼의 정식화는 어느 정도 마쳤다).
예로, 하나의 큰 문자열과 그 문자열 내부를 가리키는 여러 참조로 이루어진 Message 구조체를 생각해 보자. 다음처럼 모델링할 수 있다:
struct Message {
text: String,
headers: Vec<(&'self.text str, &'self.text str)>,
body: &'self.text str,
}
이 메시지는 보통의 방식으로 생성된다:
let text: String = parse_text();
let (headers, body) = parse_message(&text);
let message = Message { text, headers, body };
여기서 parse_message는 다음 같은 함수다
fn parse_message(text: &str) -> (
Vec<(&'text str, &'text str)>,
&'text str
) {
let mut headers = vec![];
// ...
(headers, body)
}
주목할 점은 Message에 라이프타임 매개변수가 없다는 것이다 — 외부로부터 아무것도 빌리지 않기 때문에 필요가 없다. 실제로 Message: 'static이 성립한다. 즉, 이 Message를 다른 스레드로 보낼 수도 있다:
// `Message` 값을 주고받는 채널:
let (tx, rx) = std::sync::mpsc::channel();
// 그 값을 소비하는 스레드:
std::thread::spawn(move || {
for message in rx {
// 여기서 `message`의 타입은 `Message`
process(message.body);
}
});
// 생산 측:
loop {
let message: Message = next_message();
tx.send(message);
}
대략적으로…
…즉, 이 설계들이 실용적이라는 확신이 들 만큼의 작업은 해두었지만, 앞으로의 일도 많다. :)
빌림 검사기의 인체공학(ergonomics)과 사용성을 개선하겠다고 생각할 때마다, 나는 약간의 죄책감을 느끼곤 한다. 이렇게 생각하기 즐거운 주제가 내 시간을 이렇게나 쓰게 만드는 것이 과연 옳은 일일까 하고.
RustNL에서의 대화가 내 관점을 바꿨다. 사람들에게 고충을 물어보니, 특히 애플리케이션이나 GUI를 만들려는 사람들에게서 몇 가지 같은 주제가 반복해서 들렸다.
이제 나는 ‘지식의 저주’에 빠졌던 것 같다고 생각한다. 빌림 검사기의 한계에 부딪혀 그것을 어떻게 해소해야 할지 모르는 일이 얼마나 답답한지 잊고 있었던 것이다.
이 글은 오랫동안 이어져 온 문제들을 정면으로 다루는 네 가지 변화를 제안한다:
아마 눈치챘겠지만, 이 변화들은 서로 맞물린다. Polonius는 빌림을 “place 표현식”(변수, 필드)의 관점으로 재정의한다. 이는 명시적 라이프타임 문법을 가능하게 하고, 그 문법은 다시 내부 참조의 핵심 구성요소가 된다. 한편 뷰 타입은 ‘부분적으로 빌려진’(혹은 부분적으로 초기화된!) 값에 대해 동작할 수 있는 헬퍼 메서드를 노출하는 수단을 제공한다.
이 변화들이 러스트의 복잡성에 미칠 영향을 궁금해할 수 있다. 확실히 타입 시스템이 표현할 수 있는 것들의 집합은 커진다. 하지만 내 생각에 이 변화들은, NLL처럼, 오히려 러스트 사용 경험을 전반적으로 더 단순하게 만들 범주에 속한다.
왜 그런지 보려면, 오늘 이 글에서 본 “명백히 올바른” 프로그램들 중 하나를 쓴 사용자의 입장이 되어 보자 — 예를 들어, 뷰 타입에서 보았던 WidgetFactory 코드. 오늘 이 코드를 컴파일하면 이런 에러가 나온다:
error[E0502]: cannot borrow `*self` as mutable
because it is also borrowed as immutable
--> src/lib.rs:14:17
|
12 | for widget in &self.widgets {
| -------------
| |
| immutable borrow occurs here
| immutable borrow later used here
13 | if widget.should_be_counted() {
14 | self.increment_counter();
| ^^^^^^^^^^^^^^^^^^^^^^^^
| |
| mutable borrow occurs here
최선을 다해 에러 메시지를 잘 표현하려고 해도, 이 에러는 본질적으로 혼란스럽다. ‘직관적’ 관점에서 왜 WidgetFactory가 작동하지 않는지 설명할 수 없다. 왜냐하면 개념적으로는 작동해야 하기 때문이다. 단지 우리 타입 시스템의 한계에 부딪혔을 뿐이다.
WidgetFactory가 컴파일되지 않는 이유를 이해하는 유일한 방법은 러스트 타입 시스템이 어떻게 동작하는지에 대한 엔지니어링 세부로 깊이 파고드는 것이다. 그리고 그것이야말로 사람들이 원하지 않는 학습이다. 게다가, 그렇게 깊이 파고들면 보상은 무엇인가? 잘해야 어색한 우회책을 고안해내는 정도다. 야호 🥳.7
이제 뷰 타입을 상상해 보자. 여전히 에러가 나오겠지만, 이제 그 에러에는 다음과 같은 제안이 함께 따라올 수 있다:
도움말: `increment_counter`가 접근하는 필드를 선언해
다른 함수가 그 사실에 의존할 수 있게 하세요
7 | fn increment_counter(&mut self) {
| ---------
| |
| 도움말: 접근 필드로 주석을 달아보세요: `&mut {counter} self`
선택지는 두 가지다. 첫째, 제안을 적용하고 넘어간다 — 코드가 작동한다! 둘째, 시간이 날 때 좀 더 깊이 파고들며 무슨 일이 벌어지는지 이해한다. 여기서 명시적 선언이 필요한 동기가 무엇인지(semver 상의 고려 사항 등)를 배울 수 있다.
그렇다, 타입 시스템의 새 디테일 하나를 배웠다. 하지만 당신의 일정에 맞춰 배웠고, 필요한 추가 주석 또한 충분히 납득할 만한 이유가 있었다. 야호 🥳
여기에는 또 하나의 주제가 흐른다. 컴파일러의 머릿속에서 수행되던 빌림 검사 분석을, 표현 가능한 타입으로 끌어내는 것이다. 지금의 타입은 모두 완전히 초기화되고 빌려지지 않은(unborrowed) 값만을 표현한다. 어떤 것을 순회 중이거나, 한두 필드만 이동(move)된 상태 같은 것을 타입으로 표현할 방법이 없다. 이 변화들은 그 격차를 메운다9.
안다, 마치 피터 잭슨이 “반지의 제왕: 왕의 귀환” 엔딩을 못 끝내는 것 같다. 멈추질 못하겠다! 자꾸 더 할 말이 떠오른다. 음, 이제 그만하겠다. 모두 좋은 주말 보내시길.
음, 자바로 쓰인 모든 프로그램이 데이터를 미친 듯이 공유하긴 한다. 그렇다고 모두 잘 동작하는 건 아니다. 하지만 무슨 뜻인지 알 거라 생각한다.↩︎
그리고 _변이 xor 공유_에 익숙해지는 것이 러스트를 배운다는 것의 큰 부분이라고 생각한다.↩︎
구현된 NLL 기준으로 그렇다. 원래 설계는 조건부 참조 반환을 포괄하려 했지만, 제안된 타입 시스템은 구현 가능하지 않았다. 게다가, 설계자인 내 입으로 말하자면, NLL RFC의 정식화는 좋지 않았다. 머리 아프고 이해하기 어려웠다. Polonius가 훨씬 낫다.↩︎
사실, 뷰 타입은 RFC 2229의 “분리된 클로저 캡처(disjoint closure capture)” 규칙을 더 효율적으로 구현할 수 있게 해준다. 현재는 self.widgets와 self.counter를 쓰는 클로저가 참조 2개를 저장하는데, 일종의 암묵적 “뷰 구조체”다. 실제로는 코드에 큰 영향을 주지 않는다는 걸 발견했지만, 그래도 마음에 걸린다. 뷰 타입이 있으면 1개만 저장해도 된다.↩︎
내게 뷰 타입에서 가장 큰 열린 질문은 타입에 대한 “강한 갱신(strong update)”을 어떻게 수용할지다. 예컨대 let mut wf: {} WidgetFactory = WidgetFactory {}로 완전히 초기화되지 않은 WidgetFactory 값을 만들고, 이후 wf.counter = 0 같은 대입을 허용하고 싶다. 그러면 wf의 타입을 {counter} WidgetFactory로 갱신해야 한다. 요컨대 타입에 있는 정보와 빌림 검사기의 초기화 상태 개념을 연결하고 싶은데, 그 세부를 아직 완전히 풀지 못했다.↩︎
예를 들어, 이를 가능하게 하려면 Deref의 결과 참조가, 디레퍼런스되는 값이 여기저기로 이동(move)하더라도 계속 유효함을 보장하는, 일종의 “진정한 deref(true deref)” 트레이트를 가정하고 있다. 비슷한 이유로 우리에게는 이런 트레이트가 다른 면에서도 필요하다.↩︎
못 알아챘다면 말인데, 그건 비꼬는 “야호 🥳”였다.↩︎
이 “야호 🥳”는 진심이다.↩︎
예전에 어떤 학회에서 러스트를 발표했을 때, 한 친절한 교수님이 내게 이렇게 말했다. “내 경험상, 그 상태(state)는 항상 타입 시스템에 집어넣고 싶어지기 마련이지.” 그 교수님 말이 맞았다고 생각한다. 다만 이를 최우선으로 두지 않았던 걸 후회하진 않는다(항상 할 일이 너무 많으니, 과거에 무엇이 더 나은 다음 단계였을지를 걱정하기보다, 지금 무엇이 옳은 다음 단계인지를 묻는 편이 낫다). 아무튼, 그게 _누구_였는지 기억나면 좋겠다