뷰 타입에 대한 극도로 최소한의 제안을 소개하고, Rust의 borrow checker가 메서드가 접근할 수 있는 필드를 더 정확히 알 수 있게 하는 방법을 설명합니다.
이 블로그 글에서는 view types에 대한 극도로 최소한의 제안 을 설명합니다. 이 제안은 RustNation에서 lcnr 및 Jack Huey와 나눈 대화에서 나왔습니다. 우리는 언어에 대한 여러 개선점들, 즉 “공기 중에 떠다니는”, 거의 모두가 하고 싶어 하는 것들, 그리고 그것들을 실제로 도입하려면 무엇이 필요한지에 대해 이야기했습니다.
간단한 예시부터 시작해 봅시다. MessageProcessor라는 구조체가 있고, 이것은 메시지 집합과 함께 생성된다고 가정합시다. 이 구조체는 메시지를 처리하면서 동시에 몇 가지 간단한 통계를 수집합니다:
pub struct MessageProcessor {
messages: Vec<String>,
statistics: Statistics,
}
#[non_exhaustive] // 예시와는 관련 없지만, 좋은 관례입니다!
pub struct Statistics {
pub message_count: usize,
pub total_bytes: usize,
}
메시지 프로세서의 기본 작업 흐름은 다음과 같습니다.
self.messages 벡터에 push해서 메시지를 누적합니다메시지를 누적하는 것은 쉽습니다:
impl MessageProcessor {
pub fn push_message(&mut self, message: String) {
self.messages.push(message);
}
}
단일 메시지를 처리하는 함수는 메시지 문자열의 소유권을 가져갑니다. 다른 스레드로 보내기 때문입니다. 그렇게 하기 전에 통계를 갱신합니다:
impl MessageProcessor {
fn process_message(&mut self, message: String) {
self.statistics.message_count += 1;
self.statistics.total_bytes += message.len();
// ... 그리고 메시지를 어딘가로 보내는 동작이 추가됩니다
}
}
마지막으로 필요한 함수는 누적된 메시지를 비우며 처리하는 함수입니다. 이것을 작성하는 일은 당연히 간단해야 할 것 같지만, 실제로는 그렇지 않습니다:
impl MessageProcessor {
pub fn process_pushed_messages(&mut self) {
for message in self.messages.drain(..) {
self.process_message(message); // <-- 오류: `self`가 대여됨
}
}
}
문제는 self.messages.drain(..)가 self.messages에 대한 가변 대여를 취한다는 점입니다. self.process_message를 호출하면 컴파일러는 여러분이 self.messages를 포함한 어떤 필드든 수정할 수 있다고 가정합니다. 따라서 오류를 보고합니다. 이것은 논리적이지만, 답답합니다.
경험 많은 Rust 프로그래머들은 여러 가지 우회 방법을 알고 있습니다. 예를 들어 messages 필드를 빈 벡터와 바꿔치기할 수도 있습니다. 또는 self.messages.pop()을 호출할 수도 있습니다. 혹은 process_message를 Statistics 타입의 메서드로 다시 작성할 수도 있습니다. 하지만 솔직히 말해서, 이들 모두는 최선이라고 보기 어렵습니다. 위 코드는 정말로 꽤 합리적이며, 구조를 다시 짜지 않고도 곧바로 동작하게 만들 수 있으면 좋겠습니다.
핵심 문제는 borrow checker가 process_message가 statistics 필드에만 접근한다는 사실을 모른다는 점입니다. 이 글에서는 명시적이고 꽤 제한적인 표기법에 초점을 맞추겠지만, 앞으로 이를 어떻게 확장할 수 있을지도 이야기하겠습니다.
뷰 타입의 기본 아이디어는 구조체 타입의 문법을 확장해서, 선택적으로 접근 가능한 필드 목록을 포함하도록 하는 것입니다:
RustType := StructName<...>
| StructName<...> { .. } // <-- 우리가 추가하는 것
| StructName<...> { (fields),* } // <-- 우리가 추가하는 것
MessageProcessor { statistics } 같은 타입은 “statistics 필드에만 접근할 수 있는 MessageProcessor 구조체”를 의미합니다. MessageProcessor { .. }처럼 ..도 포함할 수 있는데, 이것은 모든 필드에 접근할 수 있다는 뜻이며, 오늘날의 구조체 타입 MessageProcessor와 동등합니다.
뷰 타입은 프라이버시를 존중합니다. 즉, 애초에 messages 필드 이름을 쓸 수 있는 문맥에서만 MessageProcessor { messages }를 쓸 수 있다는 뜻입니다.
self 인자와 그 밖의 곳에서 이름 붙일 수 있습니다이를 이용하면 process_message가 statistics 필드에만 접근하면 된다고 정의할 수 있습니다:
impl MessageProcessor {
fn process_message(&mut self {statistics}, message: String) {
// ----------------------
// 축약형: `self: &mut MessageProcessor {statistics}`
// ... 이전과 동일 ...
}
}
물론 다른 인자에도 이 표기법을 사용할 수 있습니다:
fn silly_example(.., mp: &mut MessageProcessor {statistics}, ..) {
}
또한 대여 표현식도 확장해서, 대여를 통해 정확히 어떤 필드에 접근할 수 있을지 지정할 수 있게 합니다:
let messages = &mut some_variable {messages}; // 모호한 문법? 아래를 보세요.
이렇게 하면 borrow checker는 &mut MessageProcessor {messages} 타입의 값을 만들어냅니다.
눈썰미 좋은 독자라면 이것이 모호하다는 점을 알아차렸을 것입니다. 위 코드는 오늘날에도 some_variable { messages } 또는 더 장황하게는 some_variable { messages: messages } 같은 구조체 표현식에 대한 대여로 파싱될 수 있습니다. 이 문제를 어떻게 처리할지는 확신이 없습니다. 아래에서 몇 가지 대안 문법을 적어 두겠지만, 한편으로는 컴파일러가 AST를 모호한 상태로 파싱한 뒤 이름 해석 결과를 알게 되었을 때 나중에 구분하는 것도 가능 하다는 점을 덧붙이고 싶습니다.
하지만 우리의 예시에서는 사용자가 &mut 대여를 명시적으로 쓰지 않습니다. 이것은 메서드 호출의 일부로 컴파일러가 추가하는 자동 참조에서 생깁니다:
pub fn process_pushed_messages(&mut self) {
for message in self.messages.drain(..) {
self.process_message(message); // <-- 여기서 자동 참조가 발생
}
}
컴파일러는 내부적으로 self.process_message(message) 같은 메서드 호출을 process_message에 선언된 시그니처에 따라 완전 수식 형태로 다시 씁니다. 오늘날에는 그 결과가 다음과 같은 코드가 됩니다:
MessageProcessor::process_message(&mut *self, message)
하지만 이제 process_message가 &mut self { statistics }를 선언하므로, 대신 필드 집합을 지정하는 대여로 디슈가링할 수 있습니다:
MessageProcessor::process_message(&mut *self { statistics }, message)
뷰를 borrow checker에 통합하는 일은 꽤 사소합니다. borrow checker는 대여 표현식을 보면 내부적으로 “loan”을 기록하는데, 이 loan은 대여된 place, 대여 방식(mut, 공유), 그리고 대여된 lifetime 을 추적합니다. 우리가 해야 할 일은 뷰를 사용하는 각 대여에 대해 하나의 loan 대신 여러 loan을 기록하는 것뿐입니다.
예를 들어 &mut self가 있으면 self에 대한 하나의 mut loan을 기록합니다. 하지만 &mut self {field1, field2}가 있으면 mut loan 두 개, 즉 self.field1 하나와 self.field2 하나를 기록하게 됩니다.
좋습니다, 모두 합쳐 봅시다. 다음은 처음의 예시를 모은 것입니다:
pub struct MessageProcessor {
messages: Vec<String>,
statistics: Statistics,
}
#[non_exhaustive]
pub struct Statistics {
pub message_count: usize,
pub total_bytes: usize,
}
impl MessageProcessor {
pub fn push_message(&mut self, message: String) {
self.messages.push(message);
}
pub fn process_pushed_messages(&mut self) {
for message in self.messages.drain(..) {
self.process_message(message); // <-- 오류: `self`가 대여됨
}
}
fn process_message(&mut self, message: String) {
self.statistics.message_count += 1;
self.statistics.total_bytes += message.len();
// ... 그리고 메시지를 어딘가로 보내는 동작이 추가됩니다
}
}
오늘날 process_pushed_messages는 오류를 발생시킵니다:
pub fn process_pushed_messages(&mut self) {
for message in self.messages.drain(..) {
// ------------- `self.messages`를 대여함
self.process_message(message); // <-- 오류!
// --------------- `self`를 대여함
}
}
오류는 두 대여 사이의 충돌에서 발생합니다:
self.messages.drain(..)는 Iterator::drain(&mut self.messages, ..)로 디슈가링되며, 보시다시피 self.messages를 mut 대여합니다.self.process_message(..)는 MessageProcessor::process_message(&mut self, ..)로 디슈가링되며, 보시다시피 self 전체를 mut 대여하므로 self.messages와 겹칩니다.하지만 “멋진 신세계”에서는 프로그램 한 곳만 바꾸면 됩니다:
- fn process_message(&mut self, message: String) {
+ fn process_message(&mut self {statistics}, message: String) {
그러면 그 결과 process_pushed_messages 함수는 이제 borrow check를 성공적으로 통과하게 됩니다. 두 loan이 이제 서로 다른 place에 대해 발행되기 때문입니다:
self.messages.drain(..)는 Iterator::drain(&mut self.messages, ..)로 디슈가링되며 self.messages를 mut 대여합니다.self.process_message(..)는 MessageProcessor::process_message(&mut self {statistics}, ..)로 디슈가링되며 self.statistics를 mut 대여하고, 이것은 self.messages와 겹치지 않습니다.제가 강조하고 싶은 한 가지는 “뷰 타입”이 순전히 정적 구성물이며, 코드가 컴파일되는 방식을 바꾸지 않는다는 점입니다. 이것은 단지 어떤 참조를 통해 어떤 데이터에 접근할지를 borrow checker에 더 많이 알려 줄 뿐입니다. 예를 들어 process_message 메서드는 여전히 self에 대한 단일 포인터 하나를 받습니다.
이 점은 오늘날 존재하는 우회 방법들과 대조적입니다. 예를 들어 제가 위 코드를 작성한다면, process_message를 &mut Statistics를 받는 연관 함수로 다시 쓸 수도 있을 것입니다:
impl MessageProcessor {
fn process_message(statistics: &mut Statistics, message: String) {
statistics.message_count += 1;
statistics.total_bytes += message.len();
// ... 그리고 메시지를 어딘가로 보내는 동작이 추가됩니다
}
}
물론 이것은 성가십니다. self.process_message() 대신 Self::process_message(&mut self.statistics, ..)라고 써야 하기 때문입니다. 하지만 borrow check 오류는 피할 수 있습니다.
성가시다는 점 외에도, 이것은 코드가 컴파일되는 방식을 바꿉니다. 이제는 MessageProcessor에 대한 참조를 받는 대신 Statistics에 대한 참조를 받게 됩니다.
이 예시에서는 한 타입에서 다른 타입으로 바뀌는 것이 무해하지만, 다른 예시들에서는 여러 필드에 접근해야 할 수 있고, 그런 경우에는 그것들을 하나씩 따로 전달하는 편이 더 비효율적입니다.
솔직히, 그리 어렵지 않습니다. 이 작업을 맡고 싶어 하는 좋은 기여자를 찾는다면 올해 안에도 출시할 수 있다고 생각합니다.
뷰 타입에 등장하는 필드들은 그것을 이름 붙이는 코드에서 ‘보이는’ 상태여야 한다고 요구하겠습니다(여기에는 자동 참조를 통해 삽입되는 뷰 타입도 포함됩니다). 따라서 다음 코드는 오류가 됩니다:
mod m {
#[derive(Default)]
pub struct MessageProcessor {
messages: Vec<String>,
...
}
impl MessageProcessor {
pub fn process_message(&mut self {messages}, message: String) {
// ----------
// 여기서 private 필드를 참조하는 것 자체는 *합법적*이지만,
// 현재도 public 메서드가 private 타입의 인자를 받는 것이
// *합법적*이되 lint가 걸리는 것처럼, lint가 발생합니다.
// lint가 발생하는 이유는 이렇게 하면 사실상 이 메서드가
// 이 모듈 바깥에서는 호출 불가능해지기 때문입니다.
self.messages.push(message);
}
}
}
fn main() {
let mut mp = m::MessageProcessor::default();
mp.process_message(format!("Hello, world!"));
// --------------- 오류: 여기서는 필드 `messages`에 접근할 수 없음
//
// 이것은 다음으로 디슈가링됩니다:
//
// ```
// MessageProcessor::process_message(
// &mut mp {messages}, // <-- private 필드 이름을 사용함!
// format!("Hello, world!"),
// )
// ```
//
// 여기서 private 필드 `messages`의 이름을 쓰고 있으므로 오류입니다.
}
대체로 그렇습니다. 다만 뷰 타입이 public 필드를 참조한다면 사용할 수 있습니다:
#[non_exhaustive]
pub Statistics {
pub message_count: usize,
pub average_bytes: usize,
// ... 나중에 더 많은 필드가 추가될 수도 있습니다 ...
}
impl Statistics {
pub fn total_bytes(&self {message_count, average_bytes}) -> usize {
// ----------------------------
// 우리가 이 두 필드만 읽는다고 선언합니다.
self.message_count * self.average_bytes
}
}
그렇습니다! 하지만 좋은 출발점입니다. 그리고 제 경험상 이 문제는 여기서 보여 준 것 같은 private 헬퍼 메서드에서 가장 자주 발생합니다. public 문맥에서도 발생할 수는 있지만 훨씬 드물고, 그런 상황에서는 사용자에게 그룹 구조가 더 잘 드러나도록 타입을 리팩터링하는 편이 오히려 더 받아들여지기 쉽습니다. 이것이 public 경우도 고치고 싶지 않다는 뜻은 아닙니다. 단지 MVP에서 잘라 내기에 좋은 사용 사례라는 뜻입니다. 미래에는 예전에 설명했던 것처럼 abstract fields를 통해 public 필드를 다루고 싶습니다.
맞습니다! 정말 그럴 것입니다! 미래에는 제 abstract fields 블로그 글에서 설명했던 것과 같은 종류의 ‘ghost’ 또는 ‘abstract’ 필드를 보고 싶습니다. 하지만 다시 말해, 그것은 제게 “MVP 이후”의 문제처럼 보입니다.
제가 설명한 문법에서는 &mut place {field1, field2}를 명시적으로 써야 합니다. 하지만 문헌에는 이런 종류의 것을 추론하는 여러 접근법이 있으며, 그중 row polymorphism이 아마 가장 직접적으로 적용 가능할 것입니다. 저는 이런 종류의 추론을 충분히 도입할 수 있다고 생각하고, 사실 아마도 그것을 기본값으로 만들고 싶습니다. 그래서 &mut place가 항상 뷰 타입을 도입하되, 실제로는 보통 “모든 필드”로 추론되게 하는 식입니다. 하지만 그것은 Rust의 추론 시스템에 대한 만만치 않은 확장으로, 오늘날 우리가 하지 않는 새로운 종류의 추론을 도입하는 일입니다. MVP에서는 자동 참조가 압도적으로 가장 흔한 경우를 처리하도록 기대고, 나머지에는 명시적 문법을 두는 쪽으로 가고 싶습니다.
많은 응용에서는, 특히 private 메서드의 경우, 접근할 필드 목록을 직접 적는 것이 조금 어리석게 느껴질 수 있다는 점을 이해합니다. 컴파일러가 알아서 계산해야 할 것처럼 보이니까요.
반대로 이것은 Rust에서 여러 이유로 피하려는 종류의 절차 간 추론입니다:
Send 주변에서 우리가 겪는 혼란을 떠올려 보세요).제게 핵심은 단계적 도입 입니다. 우리가 무엇을 하든, 정확히 어떤 필드가 어디서 접근되는지 명시할 수 있는 방법은 필요하다고 생각합니다. 그러므로 먼저 그것을 추가해야 합니다. 추론은 나중에 추가할 수 있습니다.
또 다른 흔한 대안(그리고 저도 한동안 고려했던 것…)은 단일 참조 대신 필드들에 대한 참조를 전달하는 종류의 “디슈가링”을 추가하는 것입니다. 저는 이것이 두 가지 이유로 마음에 들지 않습니다. 첫째, 솔직히 더 복잡하다고 생각합니다! 이것은 borrow checker에 대한 꽤 직접적인 변경이지만, 그런 디슈가링은 컴파일러 전반에 코드를 흩뿌리게 되고, 진단 등도 훨씬 더 복잡하게 만들 것입니다.
둘째, 그렇게 하면 런타임에서 일어나는 일도 바꿔야 하는데, 이 예시에서 왜 그것이 필요한지 모르겠습니다. 제게는 단일 참조를 전달하는 것이 맞게 느껴집니다.
아, 맞다, 문법 모호성이 있었죠. 솔직히 저는 문법에 대해 아주 깊이 생각해 보지는 않았습니다. Struct { field1, field 2 } 같은 타입이 구조체 생성자 문법을 반영하도록 하려 했습니다. 우리는 일반적으로 타입이 표현식을 반영하도록 하려 하기 때문입니다. 하지만 물론 이것이 문제를 일으키는 대여 표현식의 모호성으로 이어집니다:
let foo = &mut some_variable { field1 };
// ------------- 이것은 변수인가, 필드 이름인가?
제가 보는 선택지는 다음과 같습니다:
None 같은 패턴의 해석이 그렇습니다(None 변수에 바인딩하는 것인가, 아니면 enum variant인가?).&mut {field1} in some_variable 같은 식입니다. 명시적 대여 형태를 직접 타이핑하는 일은 드물 것이므로, 이것도 괜찮아 보입니다.요컨대, 적어도 lang experiment 수준에서는 여기서 앞으로 나아가는 것을 막는 것은 딱히 보이지 않습니다.