`RefCell<T>`와 내부 가변성 패턴
RefCell이 대여 규칙을 런타임에 검사하는 방식을 익히고 목 객체와 Rc 조합으로 공유 데이터의 내부 가변성을 구현합니다.
내부 가변성(interior mutability)은 어떤 데이터에 대한 불변 참조자가 있을 때라도 데이터를 변경할 수 있게 해주는 러스트의 디자인 패턴입니다; 보통 이러한 동작은 대여 규칙에 의해 허용되지 않습니다.
데이터를 변경하기 위해서, 이 패턴은 데이터 구조 내에서 unsafe(안전하지 않은) 코드를 사용하여 변경과 대여를 지배하는 러스트의 일반적인 규칙을 우회합니다.
안전하지 않은 코드는 이 규칙들을 지키고 있는지에 대한 검사를 컴파일러에게 맡기는 대신 수동으로 하는 중임을 컴파일러에게 알립니다; 안전하지 않은 코드에 대해서는 18장에서 더 알아보겠습니다.
컴파일러는 대여 규칙을 준수함을 보장할 수 없을지라도, 우리가 이를 런타임에 보장할 수 있는 경우라면 내부 가변성 패턴을 쓰는 타입을 사용할 수 있습니다.
여기에 포함된 unsafe 코드는 안전한 API로 감싸져 있고, 바깥쪽 타입은 여전히 불변입니다.
내부 가변성 패턴을 따르는 RefCell<T> 타입을 살펴보면서 이 개념을
탐구해봅시다.
RefCell<T>로 런타임에 대여 규칙 집행하기
RefCell 동적 대여 상태 머신
공유 Ref를 보유한 채 try_borrow_mut의 Err를 처리하는 별도 선택 예제이다. Err 뒤 기존 guard는 유지하고 마지막 guard drop 뒤 새 RefMut를 얻는다. 본문의 borrow_mut panic이 자동 복구되어 이어 실행되는 경로가 아니다.
RUST · RUNTIME BORROW STATES
RefCell은 guard가 살아 있는 동안 대여 상태를 유지한다
보통의 전체 값 borrow/borrow_mut를 기준으로 공유·배타 대여와 guard 해제를 추적합니다. 런타임 검사는 별칭 규칙을 없애는 기능이 아닙니다.
대여 없음
borrow 또는 borrow_mut를 시작할 수 있습니다.
공유 대여 · Ref
여러 Ref를 함께 보유할 수 있습니다. 하나가 drop되어도 다른 Ref가 남으면 유지되며 마지막 Ref drop 후 대여 없음으로 돌아갑니다.
배타 대여 · RefMut
전체 값의 RefMut 하나가 활성화되면 다른 공유/가변 대여는 충돌합니다. drop 후 대여 없음으로 돌아갑니다.
충돌 요청 · 상태가 아닌 결과
borrow/borrow_mut는 panic, try_borrow/try_borrow_mut는 Err입니다. try 오류로 기존 guard가 사라지지는 않습니다.
본문 MockMessenger · &self에서 메시지 기록
self.sent_messages
.borrow_mut()
.push(String::from(message));sent_messages는 RefCell<Vec<String>>입니다. 임시 RefMut guard는 이 문장이 끝날 때 drop됩니다. 앞의 트레이트/메서드 정의 안에서 쓰는 핵심 부분입니다.
본문의 두 번째 borrow_mut · panic
let one = cell.borrow_mut();
let two = cell.borrow_mut();값 전체를 빌리는 첫 guard가 살아 있으면 둘째 호출이 panic합니다. try_borrow_mut라면 Err를 처리할 수 있습니다. cell은 본문 sent_messages를 짧게 부른 이름입니다.
내부 카운터 표현은 비공개입니다. guard의 map_split 같은 분할 API는 별도 계약입니다. RefCell은 !Sync이며 T: Send이면 값 자체의 이동은 가능합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 4노드와 6간선이다. none→shared/unique는 borrow/borrow_mut, shared→none은 마지막 Ref 종료, unique→none은 RefMut 종료다. shared→충돌과 unique→충돌은 금지 요청의 결과이며 충돌 노드는 새 대여 상태가 아니다. try 메서드의 Err가 살아 있는 guard를 자동 해제하지 않는다. 두 중앙 하향 간선은 공유/배타 대여의 선택을, 왼쪽/오른쪽 바깥 레일은 해제 후 복귀를 나타낸다. 노드를 관통하지 않고 각 본래 대상에 연결한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 대여 없는 상태 RefCell의 런타임 접근 guard가 없다. |
|
|
| 공유 대여 · try 오류 여러 Ref가 있는 동안 try_borrow_mut는 Err를 반환하고 이를 처리한다. |
|
|
| 정리 후 독점 마지막 Ref drop 뒤 RefMut를 얻고 그 guard drop 뒤 다시 비운다. |
|
|
RefCell 동적 대여 상태 머신
공유 Ref를 보유한 채 try_borrow_mut의 Err를 처리하는 별도 선택 예제이다. Err 뒤 기존 guard는 유지하고 마지막 guard drop 뒤 새 RefMut를 얻는다. 본문의 borrow_mut panic이 자동 복구되어 이어 실행되는 경로가 아니다.
RUST · RUNTIME BORROW STATES
RefCell은 guard가 살아 있는 동안 대여 상태를 유지한다
보통의 전체 값 borrow/borrow_mut를 기준으로 공유·배타 대여와 guard 해제를 추적합니다. 런타임 검사는 별칭 규칙을 없애는 기능이 아닙니다.
대여 없음
borrow 또는 borrow_mut를 시작할 수 있습니다.
공유 대여 · Ref
여러 Ref를 함께 보유할 수 있습니다. 하나가 drop되어도 다른 Ref가 남으면 유지되며 마지막 Ref drop 후 대여 없음으로 돌아갑니다.
배타 대여 · RefMut
전체 값의 RefMut 하나가 활성화되면 다른 공유/가변 대여는 충돌합니다. drop 후 대여 없음으로 돌아갑니다.
충돌 요청 · 상태가 아닌 결과
borrow/borrow_mut는 panic, try_borrow/try_borrow_mut는 Err입니다. try 오류로 기존 guard가 사라지지는 않습니다.
본문 MockMessenger · &self에서 메시지 기록
self.sent_messages
.borrow_mut()
.push(String::from(message));sent_messages는 RefCell<Vec<String>>입니다. 임시 RefMut guard는 이 문장이 끝날 때 drop됩니다. 앞의 트레이트/메서드 정의 안에서 쓰는 핵심 부분입니다.
본문의 두 번째 borrow_mut · panic
let one = cell.borrow_mut();
let two = cell.borrow_mut();값 전체를 빌리는 첫 guard가 살아 있으면 둘째 호출이 panic합니다. try_borrow_mut라면 Err를 처리할 수 있습니다. cell은 본문 sent_messages를 짧게 부른 이름입니다.
내부 카운터 표현은 비공개입니다. guard의 map_split 같은 분할 API는 별도 계약입니다. RefCell은 !Sync이며 T: Send이면 값 자체의 이동은 가능합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 4노드와 6간선이다. none→shared/unique는 borrow/borrow_mut, shared→none은 마지막 Ref 종료, unique→none은 RefMut 종료다. shared→충돌과 unique→충돌은 금지 요청의 결과이며 충돌 노드는 새 대여 상태가 아니다. try 메서드의 Err가 살아 있는 guard를 자동 해제하지 않는다. 두 중앙 하향 간선은 공유/배타 대여의 선택을, 왼쪽/오른쪽 바깥 레일은 해제 후 복귀를 나타낸다. 노드를 관통하지 않고 각 본래 대상에 연결한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 대여 없는 상태 RefCell의 런타임 접근 guard가 없다. |
|
|
| 공유 대여 · try 오류 여러 Ref가 있는 동안 try_borrow_mut는 Err를 반환하고 이를 처리한다. |
|
|
| 정리 후 독점 마지막 Ref drop 뒤 RefMut를 얻고 그 guard drop 뒤 다시 비운다. |
|
|
Rc<T>와는 다르게, RefCell<T> 타입은 가지고 있는 데이터에 대한 단일 소유권을 나타냅니다.
그렇다면, Box<T>와 같은 타입과 RefCell<T>의 다른 부분은 무엇일까요?
3장에서 배웠던 대여 규칙을 상기해봅시다.
- 어떠한 경우이든 간에, 하나의 가변 참조자 혹은 여러 개의 불변 참조자 중(둘 다가 아니고) 하나만 가질 수 있습니다.
- 참조자는 항상 유효해야 합니다.
참조자와 Box<T>를 이용할 때, 대여 규칙의 불변성은 컴파일 타임에 집행됩니다.
RefCell<T>를 이용할 때, 이 불변성은 런타임에 집행됩니다.
참조자를 가지고서 이 규칙을 어기면 컴파일러 에러를 얻게 될 것입니다.
RefCell<T>의 borrow나 borrow_mut로 대여 규칙을 어기면 런타임에 패닉이 시작됩니다.
컴파일 타임의 대여 규칙 검사는 개발 과정에서 에러를 더 일찍 잡을 수 있다는 점, 그리고 이 모든 분석이 사전에 완료되기 때문에 런타임 성능에 영향이 없다는 장점이 있습니다.
이러한 이유로 컴파일 타임의 대여 규칙을 검사하는 것이 대부분의 경우에서 가장 좋은 선택이고, 이것이 러스트의 기본 설정인 이유이기도 합니다.
런타임의 대여 규칙 검사를 하면 컴파일 타임 검사에 의해서는 허용되지 않을 메모리 안전성이 보장되는 특정 시나리오가 허용된다는 장점이 있습니다.
러스트 컴파일러와 같은 정적 분석은 태생적으로 보수적입니다.
어떤 코드 속성은 코드 분석으로는 발견이 불가능합니다.
가장 유명한 예제로 정지 문제(halting problem)가 있는데, 이는 이 책의 범위를 벗어나지만 연구하기에 흥미로운 주제입니다.
몇몇 분석이 불가능하기 때문에, 러스트 컴파일러가 어떤 코드의 소유권 규칙 준수를 확신할 수 없다면, 올바른 프로그램을 거부할지도 모릅니다; 이런 식으로 컴파일러는 보수적입니다.
러스트가 올바르지 않은 프로그램을 수용한다면, 사용자들은 러스트가 보장하는 것을 신뢰할 수 없을 것입니다.
하지만, 만일 러스트가 올바른 프로그램을 거부한다면, 프로그래머는 불편하겠지만 어떠한 재앙도 일어나지 않을 수 있습니다.
RefCell<T> 타입은 여러분의 코드가 대여 규칙을 준수한다는 것을 컴파일러는 이해하거나 보장할 수 없지만 여러분이 확신하는 경우 유용합니다.
RefCell<T>는 Sync가 아니므로 여러 스레드가 공유 참조로 동시에 접근할 수 없습니다. 다만 T: Send이면 RefCell<T> 자체의 소유권은 다른 스레드로 이동할 수 있습니다. 공유와 소유권 이동은 서로 다른 조건입니다(표준 라이브러리 계약).
RefCell<T>의 기능을 멀티스레드 프로그램에서 사용하는 방법에 대해서는 15장에서 이야기하겠습니다.
Box<T>, Rc<T>, 혹은 RefCell<T>를 선택하는 이유의 요점을 정리하면 다음과 같습니다.
Rc<T>는 동일한 데이터에 대해 복수 소유자를 가능하게 합니다;Box<T>와RefCell<T>는 단일 소유자만 갖습니다.Box<T>는 컴파일 타임에 검사되는 불변 혹은 가변 대여를 허용합니다;Rc<T>는 오직 컴파일 타임에 검사되는 불변 대여만 허용합니다;RefCell<T>는 런타임에 검사되는 불변 혹은 가변 대여를 허용합니다.RefCell<T>가 런타임에 검사되는 가변 대여를 허용하기 때문에,RefCell<T>가 불변일 때라도RefCell<T>내부의 값을 변경할 수 있습니다.
불변값 내부의 값을 변경하는 것이 내부 가변성 패턴입니다.
내부 가변성이 유용한 경우를 살펴보고 이것이 어떻게 가능한지 조사해봅시다.
내부 가변성: 불변값에 대한 가변 대여
대여 규칙의 결과로 불변값을 가지고 있을 때 이걸 가변으로 빌려올 수는 없습니다.
예를 들면, 다음 코드는 컴파일되지 않을 것입니다.
fn main() {
let x = 5;
let y = &mut x;
}
이 코드의 컴파일을 시도하면, 다음과 같은 에러를 얻게 됩니다.
$ cargo run
Compiling borrowing v0.1.0 (file:///projects/borrowing)
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
--> src/main.rs:3:13
|
2 | let x = 5;
| - help: consider changing this to be mutable: `mut x`
3 | let y = &mut x;
| ^^^^^^ cannot borrow as mutable
For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` due to previous error
하지만, 어떤 값이 자신의 메서드 내부에서는 변경되지만 다른 코드에서는 불변으로 보이게 하는 것이 유용한 경우가 있습니다.
그 값의 메서드 바깥쪽 코드에서는 값을 변경할 수 없을 것입니다.
RefCell<T>를 이용하는 것이 내부 가변성의 기능을 얻는 한 가지 방법이지만, RefCell<T>가 대여 규칙을 완벽하게 피하는 것은 아닙니다.
컴파일러의 대여 검사기는 이러한 내부 가변성을 허용하고, 대신 대여 규칙은 런타임에 검사됩니다.
만일 이 규칙을 위반하면, 컴파일러 에러 대신 panic!을 얻을 것입니다.
RefCell<T>를 이용하여 불변값을 변경할 수 있는 실질적인 예제를
실습해보고 이것이 왜 유용한지를 알아봅시다.
내부 가변성에 대한 용례: 목 객체
테스트 중 종종 프로그래머는 어떤 타입 대신 다른 타입을 사용합니다. 이를 통해 특정 동작을 관측하고 정확하게 구현되었음을 단언할 수 있습니다.
이러한 자리 표시형 타입을 테스트 더블(test double)이라고 합니다.
영화 제작에서 ‘스턴트 더블(stunt double)’이라고 부르는, 어떤 사람이 나서서 배우를 대신해 특정한 어려운 장면을 수행하는 것과 같은 의미로 생각하시면 됩니다.
테스트 더블은 테스트를 수행할 때 다른 타입 대신 나서는 것이죠.
목 객체(mock object)는 테스트 더블의 특정한 형태로서 테스트 중 어떤 일이 일어났는지 기록하여 정확한 동작이 일어났음을 단언할 수 있도록 해줍니다.
러스트에는 다른 언어들에서의 객체와 동일한 의미의 객체가 없고, 러스트에는 몇몇 다른 언어들처럼 표준 라이브러리에 미리 만들어진 목 객체 기능이 없습니다.
하지만, 당연하게도 목 객체로서 같은 역할을 수행할 구조체를 만들 수 있습니다.
여기서 테스트하려는 시나리오는 다음과 같습니다.
최댓값을 기준으로 어떤 값을 추적하여 현재 값이 최댓값에 얼마나 근접했는지에 대한 메시지를 전송하는 라이브러리를 만들려고 합니다.
이를테면 이 라이브러리는 한 명의 사용자에게 허용되고 있는 API 호출 수의 허용량을 추적하는 데 사용될 수 있습니다.
우리의 라이브러리는 어떤 값이 최댓값에 얼마나 근접했는지를 추적하고 어떤 메시지를 언제 보내야 할지에 대한 기능만 제공할 것입니다.
이 라이브러리를 사용하는 애플리케이션이 메시지를 전송하는 것에 대한 메커니즘을 제공할 예정입니다.
이 애플리케이션은 메시지를 애플리케이션 내에 집어넣거나, 이메일을 보내거나, 문자 메시지를 보내거나, 혹은 그 밖의 것들을 할 수 있습니다.
라이브러리는 그런 자세한 사항을 알 필요가 없습니다.
애플리케이션에서는 우리가 제공하게 될 Messenger라는 이름의 트레이트만 구현하면 됩니다.
예제 14-20은 라이브러리 코드를 보여줍니다.
예제 14-20: 어떤 값이 최댓값에 얼마나 근접하는지를 추적하고 특정 수준에 값이 있으면 경고해 주는 라이브러리pub trait Messenger {
fn send(&self, msg: &str);
}
pub struct LimitTracker<'a, T: Messenger> {
messenger: &'a T,
value: usize,
max: usize,
}
impl<'a, T> LimitTracker<'a, T>
where
T: Messenger,
{
pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
LimitTracker {
messenger,
value: 0,
max,
}
}
pub fn set_value(&mut self, value: usize) {
self.value = value;
let percentage_of_max = self.value as f64 / self.max as f64;
if percentage_of_max >= 1.0 {
self.messenger.send("Error: You are over your quota!");
} else if percentage_of_max >= 0.9 {
self.messenger
.send("Urgent warning: You've used up over 90% of your quota!");
} else if percentage_of_max >= 0.75 {
self.messenger
.send("Warning: You've used up over 75% of your quota!");
}
}
}
이 코드에서 한 가지 중요한 부분은 Messenger 트레이트가 self에 대한 불변 참조자와 메시지의 텍스트를 인수로 갖는 send라는 메서드 하나를 갖고 있다는 것입니다.
이 수업의 비율 판단은 양의 max와 아래처럼 작은 정수 값을 전제로 합니다. 현재 new는 max가 0인지 검사하지 않고, set_value는 이전 경고 발송 상태를 저장하지 않습니다. 같은 경고 구간을 다시 설정하면 메시지도 다시 보낼 수 있습니다. 75·90·100 경계는 코드의 >= 조건을 따르며 영문 메시지의 over라는 단어만 보고 엄격한 >로 바꾸지 않습니다.
이 트레이트는 목 객체가 실제 오브젝트와 동일한 방식으로 사용될 수 있도록 하기 위해 구현해야 하는 인터페이스입니다.
그 외에 중요한 부분은 LimitTracker 상의 set_value 메서드의 동작을 테스트해야 한다는 점입니다.
value 매개변수에 어떤 것을 넘길지 바꿀 수는 있지만, set_value는 단언에 필요한 어떤 것도 반환하지 않습니다.
Messenger 트레이트를 구현한 어떤 것과 max에 대한 특정 값과 함께 LimitTracker를 만든다면, value에 대해 다른 숫자들을 넘겼을 때 메신저가 적합한 메시지를 보냈다고 말할 수 있길 원하는 것이죠.
send를 호출했을 때 메일이나 텍스트 메시지를 보내는 대신, 보냈다고 언급하는 메시지만 추적할 목 객체가 필요합니다.
목 객체의 새 인스턴스를 생성하고, 이 목 객체를 사용하는 LimitTracker를 만들고, LimitTracker의 set_value 메서드를 호출한 다음, 목 객체가 예상한 메시지를 가지고 있는지 검사할 수 있습니다.
예제 14-21이 바로 이런 일을 하기 위한 목 객체 구현 시도이지만, 대여 검사기가 이를 허용하지 않을 것입니다.
예제 14-21: 대여 검사기가 허용하지 않는MockMessenger 구현 시도
#[cfg(test)]
mod tests {
use super::*;
struct MockMessenger {
sent_messages: Vec<String>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: vec![],
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
limit_tracker.set_value(80);
assert_eq!(mock_messenger.sent_messages.len(), 1);
}
}
이 테스트 코드는 보냈다고 알려주는 메시지를 추적하기 위한 String 값의 Vec인 sent_messages 필드를 갖는 MockMessenger 구조체를 정의합니다.
또한 연관 함수 new를 정의하여 편리하게 빈 메시지 리스트로 시작하는 새로운 MockMessenger 값을 생성할 수 있도록 합니다.
그런 다음에는 MockMessenger에 대한 Messenger 트레이트를 구현하여 MockMessenger를 LimitTracker에 넘겨줄 수 있도록 하였습니다.
send 메서드의 정의 부분에서는 매개변수로 넘겨진 메시지를 가져와서 MockMessenger 내의 sent_messages 리스트에 저장합니다.
테스트 내에서는 LimitTracker의 value에 max 값의 75퍼센트 이상인 어떤 값이 설정되었다 했을 때 무슨 일이 일어나는지 테스트하고 있습니다.
먼저 새로운 MockMessenger를 만드는데, 이는 빈 메시지 리스트로 시작될 것입니다.
그다음 새로운 LimitTracker를 만들고 여기에 새로운 MockMessenger의 참조자와 max 값 100을 매개변수로 넘깁니다.
LimitTracker의 set_value 메서드를 80 값으로 호출하였는데, 이는 75퍼센트 이상입니다.
그다음 MockMessenger가 추적하고 있는 메시지 리스트가 이제 한 개의 메시지를 가지고 있는지를 검사합니다.
하지만, 이 테스트에는 아래와 같이 한 가지 문제점이 있습니다.
$ cargo test
Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
--> src/lib.rs:58:13
|
2 | fn send(&self, msg: &str);
| ----- help: consider changing that to be a mutable reference: `&mut self`
...
58 | self.sent_messages.push(String::from(message));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` due to previous error
warning: build failed, waiting for other jobs to finish...
메시지를 추적하기 위해서 MockMessenger를 수정할 수가 없는데, 그 이유는 send 메서드가 self의 불변 참조자를 가져오기 때문입니다.
또한 에러 메시지가 제안하는 &mut self를 대신 사용하라는 것도 받아들일 수 없는데, 그렇게 되면 send의 시그니처가 Messenger 트레이트의 정의에 있는 시그니처와 맞지 않게 될 것이기 때문입니다. (편하게 한 번 시도해 보고 어떤 에러가 나오는지 보세요.)
지금이 내부 가변성의 도움을 받을 수 있는 상황입니다!
sent_messages가 RefCell<T> 내에 저장되게 하면, send 메서드는 sent_messages를 수정하여 우리에게 보이는 메시지를 저장할 수 있게 될 것입니다.
예제 14-22는 이것이 어떤 형태인지를 보여줍니다.
예제 14-22:RefCell<T>를 사용하여 바깥쪽에서는
불변으로 간주되는 한편 내부 값 변경하기
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct MockMessenger {
sent_messages: RefCell<Vec<String>>,
}
impl MockMessenger {
fn new() -> MockMessenger {
MockMessenger {
sent_messages: RefCell::new(vec![]),
}
}
}
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
self.sent_messages.borrow_mut().push(String::from(message));
}
}
#[test]
fn it_sends_an_over_75_percent_warning_message() {
// --생략--
assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
}
}
예제 14-22의 테스트에는 준비가 // --생략--으로 표시되어 있습니다. 최종 RefCell 버전의 tests 모듈만 남기고, 그 테스트 함수는 다음 전체 내용으로 교체하면 됩니다. 앞의 실패용 Vec 버전과 최종 버전을 같은 이름의 tests 모듈로 함께 붙이지 않습니다.
#[test]
fn it_sends_an_over_75_percent_warning_message() {
let mock_messenger = MockMessenger::new();
let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);
limit_tracker.set_value(80);
let messages = mock_messenger.sent_messages.borrow();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "Warning: You've used up over 75% of your quota!");
}기존 Messenger·LimitTracker 정의는 모듈 밖에 있고 use super::*로 가져옵니다. 이 함수는 메시지 개수뿐 아니라 경고 내용까지 검사합니다. 일반 cargo run의 출력 예제가 아니라 cargo test로 선택하는 단위 테스트입니다.
sent_messages 필드는 이제 Vec<String> 대신 RefCell<Vec<String>> 타입입니다.
new 함수에서는 빈 벡터를 감싼 새로운 RefCell<Vec<String>> 인스턴스를 생성합니다.
send 메서드의 구현부에서 첫 번째 매개변수는 여전히 self의 불변 대여 형태인데, 이는 트레이트의 정의와 일치합니다.
self.sent_messages의 RefCell<Vec<String>>에 있는 borrow_mut를 호출하여 RefCell<Vec<String>> 내부 값, 즉 벡터에 대한 가변 참조자를 얻습니다.
그런 다음에는 그 벡터에 대한 가변 참조자의 push를 호출하여 테스트하는 동안 보내진 메시지를 추적할 수 있습니다.
변경할 필요가 있는 마지막 부분은 단언 부분 안에 있습니다.
내부 벡터 안에 몇 개의 아이템이 있는지 보기 위해서 RefCell<Vec<String>>의 borrow를 호출하여 벡터에 대한 불변 참조자를 얻습니다.
이제 RefCell<T>가 어떻게 동작하는지 보았으니, 어떻게 동작하는지 파봅시다!
RefCell<T>로 런타임에 대여 추적하기
불변 및 가변 참조자를 만들 때는 각각 & 및 &mut 문법을 사용합니다.
RefCell<T>로는 borrow와 borrow_mut 메서드를 사용하는데, 이들은 RefCell<T>가 보유한 안전한 API 중 일부입니다.
borrow 메서드는 스마트 포인터 타입인 Ref<T>를 반환하고, borrow_mut는 스마트 포인터 타입 RefMut<T>를 반환합니다.
두 타입 모두 Deref를 구현하였기 때문에, 이들을 보통의 참조자처럼 다룰 수 있습니다.
RefCell<T>는 현재 활성화된 Ref<T>와 RefMut<T> 스마트 포인터들이 몇 개나 있는지 추적합니다.
borrow를 호출할 때마다, RefCell<T>는 불변 참조자가 활성화된 개수를 증가시킵니다.
Ref<T> 값이 스코프 밖으로 벗어날 때는 불변 대여의 개수가 하나 감소합니다.
컴파일 타임에서의 대여 규칙과 똑같이, RefCell<T>는 어떤 시점에서든 여러 개의 불변 대여 혹은 하나의 가변 대여를 가질 수 있도록 만들어 줍니다.
만일 이 규칙들을 위반한다면, RefCell<T>의 구현체는 참조자에 대해 그렇게 했을 때처럼 컴파일 에러를 내는 것이 아니라, 런타임에 panic!을 일으킬 것입니다.
예제 14-23은 예제 14-22의 send 구현을 수정한 것입니다.
고의로 같은 스코프에서 두 개의 가변 대여를 만드는 시도를 하여 RefCell<T>가 이렇게 하는 것을 런타임에 방지한다는 것을 보여주고 있습니다.
RefCell<T>가 패닉을 일으키는 것을 보기
impl Messenger for MockMessenger {
fn send(&self, message: &str) {
let mut one_borrow = self.sent_messages.borrow_mut();
let mut two_borrow = self.sent_messages.borrow_mut();
one_borrow.push(String::from(message));
two_borrow.push(String::from(message));
}
}
borrow_mut로부터 반환되는 RefMut<T> 스마트 포인터를 위한 one_borrow 변수가 만들어졌습니다.
그런 다음 또 다른 가변 대여를 같은 방식으로 two_borrow 변수에 만들어 넣었습니다.
이는 같은 스코프에 두 개의 가변 참조자를 만드는 것이고, 허용되지 않습니다.
라이브러리를 위한 테스트를 실행하면 예제 14-23의 코드는 어떠한 에러 없이 컴파일되겠지만, 테스트는 실패할 것입니다.
$ cargo test
Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
Finished test [unoptimized + debuginfo] target(s) in 0.91s
Running unittests src/lib.rs (target/debug/deps/limit_tracker-e599811fa246dbde)
running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED
failures.
---- tests::it_sends_an_over_75_percent_warning_message stdout ----
thread 'tests::it_sends_an_over_75_percent_warning_message' panicked at 'already borrowed: BorrowMutError', src/lib.rs:60:53
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures.
tests::it_sends_an_over_75_percent_warning_message
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
이 코드가 already borrowed: BorrowMutError라는 메시지와 함께 패닉을 일으켰음을 주목하세요.
이것이 바로 RefCell<T>가 런타임에 대여 규칙 위반을 다루는 방법입니다.
여기서처럼 대여 에러를 컴파일 타임이 아닌 런타임에 잡기로 선택하는 것은 개발 과정 이후에 여러분의 코드에서 실수를 발견할 가능성이 있음을 의미합니다.
여러분의 코드가 프로덕션으로 배포될 때까지 발견되지 않을 수도 있습니다.
또한, 여러분의 코드는 컴파일 타임이 아닌 런타임에 대여를 추적하는 결과로 약간의 런타임 성능 페널티를 초래할 것입니다.
하지만 RefCell<T>를 이용하면 불변값만 허용되는 컨텍스트에서도 내부 상태를 변경하여 메시지를 기록하는 목 객체를 작성할 수 있습니다.
트레이드오프가 있더라도 RefCell<T>를 사용하여 일반적인 참조자가 제공하는 것보다 더 많은 기능을 얻을 수 있습니다.
Rc<T>와 RefCell<T>를 조합하여 가변 데이터의 복수 소유자 만들기
RefCell<T>를 사용하는 일반적인 방법은 Rc<T>와 조합하는 것입니다.
Rc<T>가 어떤 데이터에 대해 복수의 소유자를 허용하지만, 그 데이터에 대한 불변 접근만 제공하는 것을 상기하세요.
만일 RefCell<T>를 들고 있는 Rc<T>를 가지게 되면, 가변이면서 동시에 복수의 소유자를 갖는 값을 얻을 수 있는 것이죠!
예를 들면, 예제 14-18에서 Rc<T>를 사용하여 여러 개의 리스트가 어떤 리스트의 소유권을 공유하도록 해준 콘스 리스트 예제를 상기해보세요.
Rc<T>가 오직 불변의 값만을 가질 수 있기 때문에, 일단 이것들을 만들면 리스트 안의 값들을 변경하는 것은 불가능했습니다.
RefCell<T>를 추가하여 이 리스트 안의 값을 변경하는 능력을 얻어봅시다.
예제 14-24는 Cons 정의 내에 RefCell<T>를 사용하여 모든 리스트 내에 저장된 값이 변경될 수 있음을 보여줍니다.
Rc<RefCell<i32>>를 사용하여
변경 가능한 List 생성하기
#[derive(Debug)]
enum List {
Cons(Rc<RefCell<i32>>, Rc<List>),
Nil,
}
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let value = Rc::new(RefCell::new(5));
let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));
let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));
*value.borrow_mut() += 10;
println!("a after = {:?}", a);
println!("b after = {:?}", b);
println!("c after = {:?}", c);
}
먼저 Rc<RefCell<i32>>의 인스턴스 값을 생성하고 value라는 이름의 변수 안에 저장하여 나중에 이에 직접 접근할 수 있게 했습니다.
그다음 value를 가지고 있는 Cons 배리언트로 List를 생성하여 a에 넣었습니다.
value는 클론되어 value가 가진 내부의 값 5에 대한 소유권이 a로 이동되거나 a가 value로부터 빌려오는 것이 아니라 a와 value 모두가 이 값에 대한 소유권을 갖도록 할 필요가 있습니다.
리스트 a는 Rc<T>로 감싸져서, b와 c 리스트를 만들 때는 둘 다 a를
참조할 수 있는데, 이는 예제 14-18에서 해본 것입니다.
a, b와 c 리스트가 생성된 이후, value의 값에 10을 더하려고 합니다.
이는 value의 borrow_mut를 호출하는 식으로 수행되었는데, 여기서 4장에서 논의했던 자동 역참조 기능이 사용되어 Rc<T>를 역참조하여 안에 있는 RefCell<T> 값을 얻어옵니다(‘-> 연산자는 없나요?’ 절을 보세요).
borrow_mut 메서드는 RefMut<T> 스마트 포인터를 반환하고, 여기에 역참조 연산자를 사용한 다음 내부 값을 변경합니다.
a, b와 c를 출력하면 이 리스트들이 모두 5가 아니라 변경된 값
15를 가지고 있는 것을 볼 수 있습니다.
$ cargo run
Compiling cons-list v0.1.0 (file:///projects/cons-list)
Finished dev [unoptimized + debuginfo] target(s) in 0.63s
Running `target/debug/cons-list`
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))
이 기술은 꽤 근사합니다!
RefCell<T>를 이용하면 표면상으로는 불변인 List를 갖게 됩니다.
하지만 데이터를 변경할 필요가 생기면 내부 가변성 접근 기능을 제공하는 RefCell<T>의 메서드를 사용하여 그렇게 할 수 있습니다.
대여 규칙의 런타임 검사는 동시에 허용되지 않는 공유·가변 대여를 막아 줍니다. 데이터 구조에 대한 이런 유연성을 위해서 약간의 속도를 맞바꾸는 것이 때로는 가치가 있습니다.
RefCell<T>를 여러 스레드에서 동시에 공유하려는 경우에는 별도의 동기화가 필요합니다.
여러 스레드에서 공유 데이터를 변경할 때 쓰는 Mutex<T>는 15장에서 다룹니다. RefCell<T>의 대여 충돌 검사와 달리 잠금을 통해 접근을 조정합니다.
예시
guard의 마지막 읽기와 종료를 구별하려면 다음 전체 프로그램처럼 try 계열로 충돌을 관찰할 수 있습니다. 오류 자체가 기존 guard를 해제하지 않습니다.
use std::cell::RefCell;
fn main() {
let cell = RefCell::new(5);
let first = cell.borrow();
let second = cell.borrow();
assert_eq!(*first + *second, 10);
drop(first);
assert!(cell.try_borrow_mut().is_err());
drop(second);
let mut unique = cell.borrow_mut();
*unique += 1;
assert!(cell.try_borrow().is_err());
drop(unique);
println!("value={}", *cell.borrow());
}예상 출력은 value=6입니다. first를 마지막으로 읽었어도 이름 있는 Ref guard는 drop 전까지 대여를 유지합니다. 첫 공유 guard만 drop하면 second가 남아 가변 대여는 여전히 실패합니다. 마지막 공유 guard를 drop한 뒤에만 배타 변경을 시작합니다.
스스로 대여와 경계 판단하기
첫째, 위 코드에서 drop(second)를 가변 대여 뒤로 옮기고 try 대신 borrow_mut()를 그대로 호출하면 어디에서 panic하나요? 실패 요청이 두 번째 공유 guard를 자동 해제하나요? 둘째, 별도의 새 목 객체와 max 100에서 set_value를 74·75·89·90·99·100·100 순서로 호출하면 각 호출이 고르는 분기와 누적 메시지 수는 무엇인가요?
상태와 경계 확인
second가 살아 있는 상태에서 cell.borrow_mut()를 호출하는 지점에서 panic이 시작되며 다음 변경문으로 진행하지 않습니다. 요청 실패가 guard를 해제하는 기능은 아닙니다. try 계열이면 Err를 처리하며 기존 대여가 계속 유지됩니다.
74는 메시지 없음, 75·89는 Warning, 90·99는 Urgent warning, 100·100은 Error입니다. 누적 개수는 0·1·2·3·4·5·6입니다. 이 else-if 구조에서는 100이 세 메시지를 한 번에 보내지 않고 가장 먼저 맞은 Error 분기만 실행합니다. 같은 값을 반복해도 한 번만 보내는 정책은 현재 코드에 없습니다.
guard 수명과 try 결과는 RefCell의 대여 API에 설명되어 있습니다.
RefCell<T>와 내부 가변성 패턴 학습 판단 기준표
RefCell<T>와 내부 가변성 패턴을 구현할 때 컴파일러가 막는 지점과 테스트로 확인할 지점을 분리해 봅니다.