[rust] 15. 스마트 포인터

About_work·2024년 8월 23일
0

rust

목록 보기
16/16

0. 들어가기 전에

0.1. 소유와 참조

1. 값을 소유한다 (Ownership)

  • Rust에서 값의 소유권은 단 하나의 변수만 가질 수 있습니다.
  • 소유권을 가지는 변수는 메모리에서 해당 값을 관리하며, 소유권이 이동하면 원래 소유자는 더 이상 그 값을 사용할 수 없습니다.
fn main() {
    let s1 = String::from("Hello");
    let s2 = s1; // s1의 소유권이 s2로 이동
    // println!("{}", s1); // 에러 발생: s1은 더 이상 유효하지 않음
    println!("{}", s2); // "Hello" 출력
}
  • 위 코드에서 s1String의 소유권을 가집니다.
  • let s2 = s1; 문장에서 s1의 소유권이 s2로 이동하므로, s1은 더 이상 유효하지 않습니다.
  • 따라서 s1을 다시 사용하려고 하면 컴파일 에러가 발생합니다.

2. 값을 참조한다 (Borrowing)

  • 값을 참조할 때는 소유권을 이동하지 않고도 값에 접근할 수 있습니다.
  • 이는 값의 주소를 참조하는 것으로, 값이 이동하지 않으므로 원래 변수도 여전히 그 값을 사용할 수 있습니다.
  • 참조에는 불변 참조와 가변 참조가 있습니다.

불변 참조 (Immutable Reference)

fn main() {
    let s1 = String::from("Hello");
    let s2 = &s1; // s1을 참조, 소유권은 이동하지 않음
    println!("{}", s1); // "Hello" 출력
    println!("{}", s2); // "Hello" 출력
}
  • 이 경우 s1s2는 둘 다 Hello를 출력할 수 있습니다. &s1s1의 참조를 가져오지만 소유권은 여전히 s1이 가지고 있습니다.

가변 참조 (Mutable Reference)

fn main() {
    let mut s1 = String::from("Hello");
    let s2 = &mut s1; // s1을 가변 참조, s2를 통해 s1을 변경할 수 있음
    s2.push_str(", world!");
    println!("{}", s2); // "Hello, world!" 출력
}
  • 위 코드에서 &mut s1s1의 가변 참조를 가져옵니다.
  • 이를 통해 s2를 사용해 s1의 값을 변경할 수 있습니다. 다만, Rust에서는 특정 시점에 하나의 가변 참조만 허용하여 데이터 경합을 방지합니다.

0.2. 책 내용

  • 포인터 (pointer)
    • 메모리의 주솟값을 담고 있는 변수에 대한 일반적인 개념
      • 이 주솟값은 어떤 다른 데이터를 참조(‘가리킵니다.’)
    • 러스트에서 가장 흔한 종류의 포인터: 참조자
      • 참조자는 & 심볼로 표시하고 이들이 가리키고 있는 값을 빌려옵니다.
      • 이들은 값을 참조하는 것 외에 다른 어떤 특별한 능력은 없으며, 오버헤드도 없습니다.

  • 스마트 포인터 (smart pointer)
    • 포인터(메모리의 주솟값을 담고 있는 변수)처럼 작동할 뿐만 아니라
    • 추가적인 메타데이터와 능력들도 가지고 있는 데이터 구조
    • 러스트의 표준 라이브러리에는 다양한 종류의 스마트 포인터들이 정의
      • 예: 참조 카운팅 (reference counting) 스마트 포인터
        • 이 포인터는 소유자의 개수를 계속 추적하고,
        • 더 이상 소유자가 없으면 데이터를 정리하는 방식으로,
        • 어떤 데이터에 대한 여러 소유자를 만들 수 있게 해 줍니다.

  • 참조자가 데이터를 빌리기만 하는 반면, 대부분의 경우 스마트 포인터는 가리킨 데이터를 소유
    • 스마트 포인터는 데이터 메모리의 주솟값을 담고 있으면서도, 그 데이터를 소유
  • String과 Vec<T>: 스마트 포인터
    • 이들이 어느 정도의 메모리를 소유하고 이를 다룰 수 있게 해 주기 때문
    • 그들은 또한 메타데이터추가 능력 또는 보장성을 갖고 있습니다.
    • 예를 들어 String은
      • 자신의 용량을 메타데이터로 저장하고
      • 자신의 데이터가 언제나 유효한 UTF-8 임을 보증
  • 스마트 포인터는 보통 구조체를 이용하여 구현
    • 스마트 포인터는 Deref와 Drop 트레이트를 구현
  • Deref 트레이트
    • 스마트 포인터가 참조자처럼 동작하도록 하여, 참조자 혹은 스마트 포인터와 함께 작동하는 코드를 작성할 수 있도록 해줍니다.
  • Drop 트레이트
    • 스마트 포인터의 인스턴스가 스코프 밖으로 벗어났을 때 실행되는 코드를 커스터마이징

  • 아래의 것들도 스마트 포인터 (데이터 메모리의 주솟값을 담고 있으면서도, 그 데이터를 소유)
    • 값을 힙에 할당하기 위한 Box<T>
    • 복수 소유권을 가능하게 하는 참조 카운팅 타입인 Rc<T>
    • 대여 규칙을 컴파일 타임 대신 런타임에 강제하는 타입인, RefCell<T>를 통해 접근 가능한 Ref<T>RefMut<T>

  • 내부 가변성 (interior mutability) 패턴: 불변 타입이 내부 값을 변경하기 위하여 API를 노출
  • 순환 참조 (reference cycles) 가 어떤 식으로 메모리가 새어나가게 할 수 있으며, 이를 어떻게 방지하는지에 대해서도 논의해 보겠습니다.

1. Box <T>를 사용하여 힙에 있는 데이터 가리키기

  • 박스는 스택이 아니라 힙에 데이터를 저장할 수 있도록 해줍니다.
    • 스택에 남는 것은 힙 데이터를 가리키는 포인터
  • 박스 3가지 사용 목적
    • 컴파일 타임에는 크기를 알 수 없는 타입이 있는데, 정확한 크기를 요구하는 컨텍스트 내에서 그 타입의 값을 사용하고 싶을 때
      • 예를 들어, 재귀적으로 정의된 자료구조에서는 크기를 컴파일 타임에 알 수 없기 때문에 이 타입을 스택에 직접 올릴 수 없습니다.
      • 이럴 때 Box<T>를 사용하여 힙에 데이터를 저장할 수 있습니다.
    • 커다란 데이터를 가지고 있고 소유권을 옮기고 싶지만, 그렇게 했을 때 데이터가 복사되지 않을 것을 보장하고 싶을 때
      • 방대한 양의 데이터의 소유권 옮기기는 긴 시간이 소요될 수 있는데, 이는 그 데이터가 스택 상에서 복사되기 때문
      • 이러한 상황에서 성능을 향상시킬 목적으로 박스 안의 힙에 그 방대한 양의 데이터를 저장할 수 있음
      • 그러면 작은 양의 포인터 데이터만 스택 상에서 복사되고, 이 포인터가 참조하는 데이터는 힙의 한 곳에 머물게 됨
    • 어떤 값을 소유하고, 이 값의 구체화된 타입보다는 특정 트레이트를 구현한 타입이라는 점만 신경 쓰고 싶을 때
      • 트레이트 객체, 17장에서 배울 거임

1.1. Box<T>을 사용하여 힙에 데이터 저장하기

fn main() {
    let b = Box::new(5);
    println!("b = {}", b);
}
  • b가 main의 끝에 도달하는 것처럼 어떤 박스가 스코프를 벗어날 때, 다른 어떤 소유된 값과 마찬가지로 할당은 해제될 것입니다.
  • 할당 해제는 (스택에 저장된) 박스 (b)와 이것이 가리키고 있는 (힙에 저장된) 데이터 (5) 모두에게 일어납니다.

1.2. 박스로 재귀적 타입 가능하게 하기

  • 재귀적 타입 (recursive type) 의 값은 자신 안에 동일한 타입의 또 다른 값을 담을 수 있습니다.
  • 러스트는 컴파일 타임에 어떤 타입이 얼마만큼의 공간을 차지하는지 알아야 하기 때문에 재귀적 타입은 문제를 일으킵니다.
  • 재귀적 타입의 값 중첩은 이론적으로 무한히 계속될 수 있으므로, 러스트는 이 값에 얼마만큼의 공간이 필요한지 알 수 없습니다.
  • 박스는 알려진 크기를 갖고 있으므로, 재귀적 타입의 정의에 박스를 집어넣어서 재귀적 타입을 가능하게 할 수 있습니다.
  • 재귀적 타입의 예제로, 콘스 리스트 (cons list)

1.2.1. 콘스 리스트에 대한 더 많은 정보

  • 1, 2, 3 리스트를 담고 있는 콘스 리스트를 각각의 쌍을 괄호로 묶어서 표현한 의사 코드
  • (1, (2, (3, Nil)))
  • 콘스 리스트의 각 아이템은 두 개의 요소를 담고 있습니다: 현재 아이템의 값과 다음 아이템이지요.
  • 리스트의 마지막 아이템은 다음 아이템 없이 Nil 이라 불리는 값을 담고 있습니다.
  • 콘스 리스트는 cons 함수를 재귀적으로 호출함으로써 만들어집니다.
  • 재귀의 기본 케이스를 의미하는 표준 이름이 바로 Nil입니다.
    • 6장의 ‘널 (null)’ 혹은 ‘닐 (nil)’ 개념과 동일하지 않다는 점을 주의하세요.
enum List {
    Cons(i32, List),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}
  • 여기서 마지막의 List는 Nil로써, 리스트의 끝을 알리는 비재귀적인 배리언트
  • Cons는 i32의 크기에 List 크기를 더한 만큼의 공간을 필요로 합니다.

1.2.2. 비재귀적 타입의 크기 계산하기

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}
  • Message 값을 할당하기 위해 필요한 공간의 양을 결정하기 위해서,
    • 러스트는 각 배리언트들의 내부를 보면서
    • 어떤 배리언트가 가장 많은 공간을 필요로 하는지를 알아봅니다.
  • 하나의 배리언트만 사용될 것이기 때문에, Message 값이 필요로 하는 가장 큰 공간은
    • 배리언트 중에서 가장 큰 것을 저장하는 데 필요한 공간

1.2.3. Box<T>를 이용하여 알려진 크기를 가진 재귀적 타입 만들기

  • Box<T>가 포인터이기 때문에, 러스트는 언제나 Box<T>가 필요로 하는 공간이 얼마인지 알고 있습니다: 포인터의 크기는 그것이 가리키고 있는 데이터의 양에 따라 변경되지 않습니다.
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
  • Cons 배리언트에는 i32와 박스의 포인터 데이터를 저장할 공간을 더한 크기가 필요합니다.
  • 박스는 그저 간접 및 힙 할당만을 제공할 뿐
  • Box<T> 타입은 Deref 트레이트를 구현하고 있기 때문에 스마트 포인터이며,
    • 이는 Box<T> 값이 참조자와 같이 취급되도록 허용해 줍니다.
  • Box<T> 값이 스코프 밖으로 벗어날 때, 박스가 가리키고 있는 힙 데이터도 마찬가지로 정리되는데 이는 Drop 트레이트의 구현 때문에 그렇습니다.

2. Deref 트레이트로 스마트 포인터를 보통의 참조자처럼 취급하기

  • Deref 트레이트의 deref 메서드가 자동으로 호출되는 시점은 아래 2가지
    • *(역참조 연산자)가 호출될 때
    • 함수의 인수로 &참조자가 호출될 때
  • Deref 트레이트
    • 스마트 포인터가 참조자처럼 동작하도록 하여, 참조자 혹은 스마트 포인터와 함께 작동하는 코드를 작성할 수 있도록 해줍니다.

2.1. 포인터를 따라가서 값 얻기

  • *(역참조 연산자)가 보통의 참조자에 대해 동작하는 방식을 살펴봅시다.
  • 보통의 참조자는 포인터의 한 종류이고, 포인터에 대해 생각하는 방법 하나는 어딘가에 저장된 값을 가리키는 화살표처럼 생각하는 것
fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
  • 숫자와 숫자에 대한 참조자를 비교하는 것은 이 둘이 서로 다른 타입이므로 허용되지 않습니다.
  • *를 사용하여 해당 참조자를 따라가서 그것이 가리키고 있는 값을 얻어내야 합니다.

2.2. Box<T>를 참조자처럼 사용하기

  • 참조자: 데이터의 주소를 가리킨다 + 데이터를 대여한다
  • Box<T>에 사용된 역참조 연산자는, 예제 15-6의 참조자에 사용된 역참조 연산자와 동일한 방식으로 기능

2.3. 자체 스마트 포인터 정의하기

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
  • x는 Copy 트레이트를 구현한 i32 타입이기 때문에, MyBox::new(x)를 호출할 때 값이 복사됩니다.
    • Copy된 값은 새로운 위치에 동일한 값을 가지게 되고, 원래 변수 x는 여전히 유효하고 동일한 값을 유지합니다.
  • Copy 트레이트를 구현하지 않은 타입
    • 힙 메모리에 데이터를 저장하는 타입들
      • String / Vec / HashMap<K, V>
      • Box<T> / Rc<T> / Mutex<T>
    • 값이 다른 변수에 할당되면 소유권이 이동하게 됩니다.
    • 이로 인해 원래 변수를 더 이상 사용할 수 없게 되어, 소유권이 한 번에 하나의 변수에만 유지되도록 보장됩니다.

2.4. Deref 트레이트를 구현하여 임의의 타입을 참조자처럼 다루기

  • 어떤 트레이트를 구현하기 위해서는
    • 그 트레이트가 요구하는 메서드에 대한 구현체를 제공해야 합니다.
  • 표준 라이브러리가 제공하는 Deref 트레이트는 deref라는 이름의 메서드 하나를 구현하도록 요구하는데,
    • 이 함수는 self를 빌려와서 내부 데이터의 참조자를 반환
  • .0이 튜플 구조체의 첫 번째 값에 접근한다는 것을 상기하세요.
  • Deref 트레이트가 없으면 컴파일러는 오직 & 참조자들만 역참조할 수 있습니다.
  • *y에 들어서면 러스트 뒤편에서는 실제로 아래와 같은 코드가 동작
    • *(y.deref())
  • deref 메서드가 값의 참조자를 반환하고, *(y.deref())에서의 괄호 바깥의 일반 역참조가 여전히 필요한 이유는 소유권 시스템과 함께 작동시키기 위해서입니다.
  • 만일 deref 메서드가 값의 참조자 대신 값을 직접 반환했다면, 그 값은 self 바깥으로 이동할 것입니다.
    • 위의 경우 혹은 역참조 연산자를 사용하는 대부분의 경우에서는 MyBox<T> 내부의 값에 대한 소유권을 얻으려는 것이 아닙니다.

2.5. 함수와 메서드를 이용한 암묵적 역참조 강제 변환

  • Deref 트레이트의 deref 메서드가 자동으로 호출되는 시점은 아래 2가지
    • *(역참조 연산자)가 호출될 때
    • 함수의 인수로 &참조자가 호출될 때 (역참조 강제 변환)
  • 역참조 강제 변환 (deref coercion) 은 Deref를 구현한 어떤 타입의 참조자다른 타입의 참조자로 바꿔줍니다.
    • 예를 들어, 역참조 강제 변환은 &String을 &str로 바꿔줄 수 있는데,
      • 이는 String의 Deref 트레이트 구현이 그렇게 &str을 반환하도록 했기 때문
  • 역참조 강제 변환은 러스트가 함수와 메서드의 인수에 대해 수행해 주는 편의성 기능이고,
    • Deref 트레이트를 구현한 타입에 대해서만 동작합니다.
  • 이는 어떤 특정한 타입값에 대한 참조자를 함수 혹은 메서드의 인수로 전달하는데
    • 이 함수나 메서드의 정의에는 그 매개변수 타입이 맞지 않을 때 자동으로 발생
  • 일련의 deref 메서드 호출이 인수로 제공한 타입을 매개변수로서 필요한 타입으로 변경해 줌
fn hello(name: &str) {
    println!("Hello, {name}!");
}
  • 인수로 넣어진 타입에 대해 Deref 트레이트가 정의되어 있다면, 러스트는 해당 타입을 분석하고 Deref::deref를 필요한 만큼 사용하여 매개변수 타입과 일치하는 참조자를 얻을 것입니다. - Deref::deref가 추가되어야 하는 횟수는 컴파일 타임에 분석되므로, 역참조 강제 변환의 이점을 얻는 데에 관해서 어떠한 런타임 페널티도 없습니다!

2.6. 역참조 강제 변환이 가변성과 상호작용하는 법

  • Deref 트레이트를 사용하여 불변 참조자에 대한 *를 오버라이딩하는 방법과 비슷한 방식으로,
  • DerefMut 트레이트를 사용하여 가변 참조자에 대한 * 연산자를 오버라이딩
  • 중요
    • T: Deref<Target=U>일 때 &T에서 &U로
    • T: DerefMut<Target=U>일 때 &mut T에서 &mut U로
    • T: Deref<Target=U>일 때 &mut T에서 &U로
  • 러스트는 가변 참조자를 불변 참조자로 강제할 수도 있습니다.
    • 하지만 그 역은 불가능하며, 불변 참조자는 가변 참조자로 결코 강제되지 않을 것입니다.
  • 대여 규칙에 의거하여, 가변 참조자가 있을 경우에는 그 가변 참조자가 해당 데이터에 대한 유일한 참조자여야 합니다.

3. Drop 트레이트로 메모리 정리 코드 실행하기

  • 스마트 포인터 패턴에서 중요한 트레이트 그 두 번째는 Drop인데, 이는 어떤 값이 스코프 밖으로 벗어나려고 할 때 무슨 일을 할지 커스터마이징하게끔 해줍니다.
  • 어떠한 타입이든 Drop 트레이트를 구현할 수 있고, 이 코드가 파일이나 네트워크 연결 같은 자원 해제에 사용되게 할 수 있습니다.
  • 스마트 포인터에 대한 맥락에서 Drop을 소개하는 이유
    • Drop 트레이트의 기능이 스마트 포인터를 구현할 때 거의 항상 이용되기 때문
    • 예를 들어 Box<T>가 버려질 때는 이 박스가 가리키고 있는 힙 공간의 할당을 해제
  • 러스트에서는 값이 스코프 밖으로 벗어날 때마다 실행되는 특정 코드를 지정할 수 있고, 컴파일러가 이 코드를 자동으로 삽입해 줄 것
  • Drop 트레이트는 drop이라는 이름의 메서드 하나를 구현해야 하는데,
    • 이 메서드는 self에 대한 가변 참조자를 매개변수로 갖습니다.
    • drop 메서드 = 소멸자 (destructor)
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("my stuff"),
    };
    let d = CustomSmartPointer {
        data: String::from("other stuff"),
    };
    println!("CustomSmartPointers created.");
}
CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!
  • 변수들은 만들어진 순서의 역순으로 버려짐

3.1. std::mem::drop 으로 값을 일찍 버리기

  • 가끔은 어떤 값을 일찍 정리하고 싶을 때도 있습니다.
  • 한 가지 예는 락을 관리하는 스마트 포인터를 이용할 때입니다:
    • 강제로 drop 메서드를 실행하여 락을 해제해서, 같은 스코프의 다른 코드에서 해당 락을 얻도록 하고 싶을 수도 있지요.
  • c.drop() 처럼, 명시적 호출이 불가능
  • std::mem::drop 함수Drop 트레이트에 있는 drop 메서드와는 다릅니다.
    • std::mem::drop 함수는 프렐루드에 구현되어 있음!
fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    drop(c);
    println!("CustomSmartPointer dropped before the end of main.");
}

4. Rc<T>, 참조 카운트 스마트 포인터

  • Rc: Reference Count
  • Rc<T>
    • 복수 소유권을 가능하게 하는 참조 카운팅 타입
    • 소유자의 개수를 계속 추적하고, 더 이상 소유자가 없으면 데이터를 정리
  • 하나의 값이 여러 개의 소유자를 가질 수 있는 경우도 있습니다.
    • 예를 들어,
      • 그래프 데이터 구조에서 여러 에지가 동일한 노드를 가리킬 수도 있고,
      • 그 노드는 개념적으로 해당 노드를 가리키는 모든 에지에 의해 소유됨
  • Rc<T> 타입은 어떤 값의 참조자 개수를 계속 추적하여 해당 값이 계속 사용 중인지를 판단합니다.
  • 만일 어떤 값에 대한 참조자가 0개라면 이 값의 메모리 정리를 하더라도 유효하지 않은 참조자가 발생하지 않을 수 있습니다.
  • Rc<T>를 거실의 TV라고 상상해 봅시다.
    • 한 사람이 TV를 보러 들어올 때 TV를 켭니다.
    • 다른 사람들은 거실로 들어와서 TV를 볼 수 있습니다.
    • 마지막 사람이 거실을 나선다면, TV는 더 이상 사용되고 있지 않으므로 끕니다.
  • Rc<T>는 오직 싱글스레드 시나리오용이라는 점을 주의

4.1. Rc<T>를 사용하여 데이터 공유하기

  • 위 코드 컴파일 안되는 이유?
    • Cons 배리언트는 자신이 들고 있는 데이터를 소유하므로,
    • b 리스트를 만들 때 a는 b 안으로 이동되어 b의 소유가 됩니다.
    • 그다음 c를 생성할 때 a를 다시 사용하려 할 경우는 허용되지 않는데, 이미 a가 이동되었기 때문

  • Box<T>의 자리에 Rc<T>를 이용하는 형태로 List의 정의를 바꾸겠습니다.
    • Rc::new 를 하면 -> Rc<List> 가 생성됨
  • b를 만들 때는 a의 소유권을 얻는 대신, a를 가지고 있는 Rc<List>를 클론할 것인데,
    • 이는 참조자의 개수를 하나에서 둘로 증가시키고
    • a와 b가 Rc<List> 안에 있는 데이터의 소유권을 공유하도록 해줍니다.
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}

4.2. Rc<T>를 클론하는 것은 참조 카운트를 증가시킵니다

  • Rc::clone를 호출하여 참조 카운트를 증가시켜야 했던 것과 달리
    • 참조 카운트를 감소시키기 위해 어떤 함수를 호출할 필요는 없습니다:
    • Rc<T> 값이 스코프 밖으로 벗어나면, Drop 트레이트의 구현체가 자동으로 참조 카운트를 감소시킵니다.
  • Rc<T>불변 참조자를 통하여 읽기 전용으로 프로그램의 여러 부분에서 데이터를 공유하도록 해줍니다.

5. RefCell<T>와 내부 가변성 패턴

  • 내부 가변성 (interior mutability)
    • 어떤 데이터에 대한 불변 참조자가 있을 때라도, 데이터를 변경할 수 있게 해주는 러스트의 디자인 패턴
    • 보통 이러한 동작은 대여 규칙에 의해 허용되지 않습니다.
  • 안전하지 않은 코드는 이 규칙들을 지키고 있는지에 대한 검사를
    • 컴파일러에게 맡기는 대신
    • 수동으로 하는 중임을 컴파일러에게 알립니다
  • 컴파일러는 대여 규칙을 준수함을 보장할 수 없을지라도, 우리가 이를 런타임에 보장할 수 있는 경우라면 내부 가변성 패턴을 쓰는 타입을 사용할 수 있습니다.
    • 여기에 포함된 unsafe 코드는 안전한 API로 감싸져 있고,
    • 바깥쪽 타입은 여전히 불변

5.1. RefCell<T>으로 런타임에 대여 규칙 집행하기

  • Rc<T>와는 다르게, RefCell<T> 타입은 가지고 있는 데이터에 대한 단일 소유권
  • 참조자와 Box<T>를 이용할 때,
    • 대여 규칙의 불변성은 컴파일 타임에 집행됩니다.
    • 컴파일 타임의 대여 규칙 검사는 아래 2가지 장점
      • 개발 과정에서 에러를 더 일찍 잡을 수 있다는 점,
      • 그리고 이 모든 분석이 사전에 완료되기 때문에 런타임 성능에 영향이 없다
  • RefCell<T>를 이용할 때,
    • 이 불변성은 런타임에 집행됩니다.
    • 특정 메모리 안정성 시나리오가 허용된다는 장점
    • 여러분의 코드가 대여 규칙을 준수한다는 것을 (컴파일러는 이해하거나 보장할 수 없지만) 여러분이 확신하는 경우 유용
  • Rc<T>와 유사하게, RefCell<T>은 싱글스레드 시나리오 내에서만 사용 가능

-Rc<T>는 동일한 데이터에 대해 복수 소유자를 가능하게 합니다;

  • Box<T>RefCell<T>은 단일 소유자만 갖습니다.
  • Box<T>는 컴파일 타임에 검사 되는 불변 혹은 가변 대여를 허용합니다;
    • Rc<T>는 오직 컴파일 타임에 검사 되는 불변 대여만 허용합니다;
    • RefCell<T>런타임에 검사되는 불변 혹은 가변 대여를 허용합니다.
  • RefCell<T>런타임에 검사 되는 가변 대여를 허용하기 때문에,
    • RefCell<T>이 불변일 때라도 RefCell<T> 내부의 값을 변경할 수 있습니다.

5.2. 내부 가변성: 불변값에 대한 가변 대여

  • 불변값 내부의 값을 변경하는 것이 내부 가변성 패턴
  • TODO
  • 하지만, 어떤 값이 자신의 메서드 내부에서는 변경되지만, 다른 코드에서는 불변으로 보이게 하는 것이 유용한 경우가 있습니다.
    • 그 값의 메서드 바깥쪽 코드에서는 값을 변경할 수 없을 것입니다.
  • RefCell<T>을 이용하는 것이 내부 가변성의 기능을 얻는 한 가지 방법이지만,
    • RefCell<T>이 대여 규칙을 완벽하게 피하는 것은 아닙니다:
  • 컴파일러의 borrow checker는 이러한 내부 가변성을 허용하고,
    • 대신 대여 규칙은 런타임에 검사 됩니다.
  • 만일 이 규칙을 위반하면, 컴파일러 에러 대신 panic!을 얻을 것입니다.

5.2.1. 내부 가변성에 대한 용례: 목 객체

  • 테스트 중 종종 프로그래머는 어떤 타입 대신 다른 타입을 사용하게 되는데, 이러한 자리 표시형 타입을 테스트 더블 (test double) 이라고 합니다.
    • 목 객체 (mock object) 는 테스트 더블의 특정한 형태로서, 테스트 중 어떤 일이 일어났는지 기록
  • 우리의 라이브러리는
    • 어떤 값이 최댓값에 얼마나 근접했는지를 추적하고
    • 어떤 메시지를 언제 보내야 할지에 대한 기능만 제공
  • 이 라이브러리를 사용하는 애플리케이션이
    • 메시지를 전송하는 것에 대한 메커니즘을 제공할 예정입니다:
    • 이 애플리케이션은 메시지를 애플리케이션 내에 집어넣거나, 이메일을 보내거나, 문자 메시지를 보내거나, 혹은 그 밖의 것들을 할 수 있습니다.
  • 라이브러리는 그런 자세한 사항을 알 필요가 없습니다.
    • 필요한 모든 것은 우리가 제공하게 될 Messenger라는 이름의 트레이트를 구현하는 것

5.2.2. RefCell<T>로 런타임에 대여 추적하기

5.2.3. Rc<T>RefCell<T>를 조합하여 가변 데이터의 복수 소유자 만들기


6. 순환 참조는 메모리 누수를 발생시킬 수 있습니다

------# 0. 들어가기 전에

0.1. 소유와 참조

1. 값을 소유한다 (Ownership)

  • Rust에서 값의 소유권은 단 하나의 변수만 가질 수 있습니다.
  • 소유권을 가지는 변수는 메모리에서 해당 값을 관리하며, 소유권이 이동하면 원래 소유자는 더 이상 그 값을 사용할 수 없습니다.
fn main() {
    let s1 = String::from("Hello");
    let s2 = s1; // s1의 소유권이 s2로 이동
    // println!("{}", s1); // 에러 발생: s1은 더 이상 유효하지 않음
    println!("{}", s2); // "Hello" 출력
}
  • 위 코드에서 s1String의 소유권을 가집니다.
  • let s2 = s1; 문장에서 s1의 소유권이 s2로 이동하므로, s1은 더 이상 유효하지 않습니다.
  • 따라서 s1을 다시 사용하려고 하면 컴파일 에러가 발생합니다.

2. 값을 참조한다 (Borrowing)

  • 값을 참조할 때는 소유권을 이동하지 않고도 값에 접근할 수 있습니다.
  • 이는 값의 주소를 참조하는 것으로, 값이 이동하지 않으므로 원래 변수도 여전히 그 값을 사용할 수 있습니다.
  • 참조에는 불변 참조와 가변 참조가 있습니다.

불변 참조 (Immutable Reference)

fn main() {
    let s1 = String::from("Hello");
    let s2 = &s1; // s1을 참조, 소유권은 이동하지 않음
    println!("{}", s1); // "Hello" 출력
    println!("{}", s2); // "Hello" 출력
}
  • 이 경우 s1s2는 둘 다 Hello를 출력할 수 있습니다. &s1s1의 참조를 가져오지만 소유권은 여전히 s1이 가지고 있습니다.

가변 참조 (Mutable Reference)

fn main() {
    let mut s1 = String::from("Hello");
    let s2 = &mut s1; // s1을 가변 참조, s2를 통해 s1을 변경할 수 있음
    s2.push_str(", world!");
    println!("{}", s2); // "Hello, world!" 출력
}
  • 위 코드에서 &mut s1s1의 가변 참조를 가져옵니다.
  • 이를 통해 s2를 사용해 s1의 값을 변경할 수 있습니다. 다만, Rust에서는 특정 시점에 하나의 가변 참조만 허용하여 데이터 경합을 방지합니다.

0.2. 책 내용

  • 포인터 (pointer)
    • 메모리의 주솟값을 담고 있는 변수에 대한 일반적인 개념
      • 이 주솟값은 어떤 다른 데이터를 참조(‘가리킵니다.’)
    • 러스트에서 가장 흔한 종류의 포인터: 참조자
      • 참조자는 & 심볼로 표시하고 이들이 가리키고 있는 값을 빌려옵니다.
      • 이들은 값을 참조하는 것 외에 다른 어떤 특별한 능력은 없으며, 오버헤드도 없습니다.

  • 스마트 포인터 (smart pointer)
    • 포인터(메모리의 주솟값을 담고 있는 변수)처럼 작동할 뿐만 아니라
    • 추가적인 메타데이터와 능력들도 가지고 있는 데이터 구조
    • 러스트의 표준 라이브러리에는 다양한 종류의 스마트 포인터들이 정의
      • 예: 참조 카운팅 (reference counting) 스마트 포인터
        • 이 포인터는 소유자의 개수를 계속 추적하고,
        • 더 이상 소유자가 없으면 데이터를 정리하는 방식으로,
        • 어떤 데이터에 대한 여러 소유자를 만들 수 있게 해 줍니다.

  • 참조자가 데이터를 빌리기만 하는 반면, 대부분의 경우 스마트 포인터는 가리킨 데이터를 소유
    • 스마트 포인터는 데이터 메모리의 주솟값을 담고 있으면서도, 그 데이터를 소유
  • String과 Vec<T>: 스마트 포인터
    • 이들이 어느 정도의 메모리를 소유하고 이를 다룰 수 있게 해 주기 때문
    • 그들은 또한 메타데이터추가 능력 또는 보장성을 갖고 있습니다.
    • 예를 들어 String은
      • 자신의 용량을 메타데이터로 저장하고
      • 자신의 데이터가 언제나 유효한 UTF-8 임을 보증
  • 스마트 포인터는 보통 구조체를 이용하여 구현
    • 스마트 포인터는 Deref와 Drop 트레이트를 구현
  • Deref 트레이트
    • 스마트 포인터가 참조자처럼 동작하도록 하여, 참조자 혹은 스마트 포인터와 함께 작동하는 코드를 작성할 수 있도록 해줍니다.
  • Drop 트레이트
    • 스마트 포인터의 인스턴스가 스코프 밖으로 벗어났을 때 실행되는 코드를 커스터마이징

  • 아래의 것들도 스마트 포인터 (데이터 메모리의 주솟값을 담고 있으면서도, 그 데이터를 소유)
    • 값을 힙에 할당하기 위한 Box<T>
    • 복수 소유권을 가능하게 하는 참조 카운팅 타입인 Rc<T>
    • 대여 규칙을 컴파일 타임 대신 런타임에 강제하는 타입인, RefCell<T>를 통해 접근 가능한 Ref<T>RefMut<T>

  • 내부 가변성 (interior mutability) 패턴: 불변 타입이 내부 값을 변경하기 위하여 API를 노출
  • 순환 참조 (reference cycles) 가 어떤 식으로 메모리가 새어나가게 할 수 있으며, 이를 어떻게 방지하는지에 대해서도 논의해 보겠습니다.

1. Box <T>를 사용하여 힙에 있는 데이터 가리키기

  • 박스는 스택이 아니라 힙에 데이터를 저장할 수 있도록 해줍니다.
    • 스택에 남는 것은 힙 데이터를 가리키는 포인터
  • 박스 3가지 사용 목적
    • 컴파일 타임에는 크기를 알 수 없는 타입이 있는데, 정확한 크기를 요구하는 컨텍스트 내에서 그 타입의 값을 사용하고 싶을 때
      • 예를 들어, 재귀적으로 정의된 자료구조에서는 크기를 컴파일 타임에 알 수 없기 때문에 이 타입을 스택에 직접 올릴 수 없습니다.
      • 이럴 때 Box<T>를 사용하여 힙에 데이터를 저장할 수 있습니다.
    • 커다란 데이터를 가지고 있고 소유권을 옮기고 싶지만, 그렇게 했을 때 데이터가 복사되지 않을 것을 보장하고 싶을 때
      • 방대한 양의 데이터의 소유권 옮기기는 긴 시간이 소요될 수 있는데, 이는 그 데이터가 스택 상에서 복사되기 때문
      • 이러한 상황에서 성능을 향상시킬 목적으로 박스 안의 힙에 그 방대한 양의 데이터를 저장할 수 있음
      • 그러면 작은 양의 포인터 데이터만 스택 상에서 복사되고, 이 포인터가 참조하는 데이터는 힙의 한 곳에 머물게 됨
    • 어떤 값을 소유하고, 이 값의 구체화된 타입보다는 특정 트레이트를 구현한 타입이라는 점만 신경 쓰고 싶을 때
      • 트레이트 객체, 17장에서 배울 거임

1.1. Box<T>을 사용하여 힙에 데이터 저장하기

fn main() {
    let b = Box::new(5);
    println!("b = {}", b);
}
  • b가 main의 끝에 도달하는 것처럼 어떤 박스가 스코프를 벗어날 때, 다른 어떤 소유된 값과 마찬가지로 할당은 해제될 것입니다.
  • 할당 해제는 (스택에 저장된) 박스 (b)와 이것이 가리키고 있는 (힙에 저장된) 데이터 (5) 모두에게 일어납니다.

1.2. 박스로 재귀적 타입 가능하게 하기

  • 재귀적 타입 (recursive type) 의 값은 자신 안에 동일한 타입의 또 다른 값을 담을 수 있습니다.
  • 러스트는 컴파일 타임에 어떤 타입이 얼마만큼의 공간을 차지하는지 알아야 하기 때문에 재귀적 타입은 문제를 일으킵니다.
  • 재귀적 타입의 값 중첩은 이론적으로 무한히 계속될 수 있으므로, 러스트는 이 값에 얼마만큼의 공간이 필요한지 알 수 없습니다.
  • 박스는 알려진 크기를 갖고 있으므로, 재귀적 타입의 정의에 박스를 집어넣어서 재귀적 타입을 가능하게 할 수 있습니다.
  • 재귀적 타입의 예제로, 콘스 리스트 (cons list)

1.2.1. 콘스 리스트에 대한 더 많은 정보

  • 1, 2, 3 리스트를 담고 있는 콘스 리스트를 각각의 쌍을 괄호로 묶어서 표현한 의사 코드
  • (1, (2, (3, Nil)))
  • 콘스 리스트의 각 아이템은 두 개의 요소를 담고 있습니다: 현재 아이템의 값과 다음 아이템이지요.
  • 리스트의 마지막 아이템은 다음 아이템 없이 Nil 이라 불리는 값을 담고 있습니다.
  • 콘스 리스트는 cons 함수를 재귀적으로 호출함으로써 만들어집니다.
  • 재귀의 기본 케이스를 의미하는 표준 이름이 바로 Nil입니다.
    • 6장의 ‘널 (null)’ 혹은 ‘닐 (nil)’ 개념과 동일하지 않다는 점을 주의하세요.
enum List {
    Cons(i32, List),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}
  • 여기서 마지막의 List는 Nil로써, 리스트의 끝을 알리는 비재귀적인 배리언트
  • Cons는 i32의 크기에 List 크기를 더한 만큼의 공간을 필요로 합니다.

1.2.2. 비재귀적 타입의 크기 계산하기

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}
  • Message 값을 할당하기 위해 필요한 공간의 양을 결정하기 위해서,
    • 러스트는 각 배리언트들의 내부를 보면서
    • 어떤 배리언트가 가장 많은 공간을 필요로 하는지를 알아봅니다.
  • 하나의 배리언트만 사용될 것이기 때문에, Message 값이 필요로 하는 가장 큰 공간은
    • 배리언트 중에서 가장 큰 것을 저장하는 데 필요한 공간

1.2.3. Box<T>를 이용하여 알려진 크기를 가진 재귀적 타입 만들기

  • Box<T>가 포인터이기 때문에, 러스트는 언제나 Box<T>가 필요로 하는 공간이 얼마인지 알고 있습니다: 포인터의 크기는 그것이 가리키고 있는 데이터의 양에 따라 변경되지 않습니다.
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
  • Cons 배리언트에는 i32와 박스의 포인터 데이터를 저장할 공간을 더한 크기가 필요합니다.
  • 박스는 그저 간접 및 힙 할당만을 제공할 뿐
  • Box<T> 타입은 Deref 트레이트를 구현하고 있기 때문에 스마트 포인터이며,
    • 이는 Box<T> 값이 참조자와 같이 취급되도록 허용해 줍니다.
  • Box<T> 값이 스코프 밖으로 벗어날 때, 박스가 가리키고 있는 힙 데이터도 마찬가지로 정리되는데 이는 Drop 트레이트의 구현 때문에 그렇습니다.

2. Deref 트레이트로 스마트 포인터를 보통의 참조자처럼 취급하기

  • Deref 트레이트의 deref 메서드가 자동으로 호출되는 시점은 아래 2가지
    • *(역참조 연산자)가 호출될 때
    • 함수의 인수로 &참조자가 호출될 때
  • Deref 트레이트
    • 스마트 포인터가 참조자처럼 동작하도록 하여, 참조자 혹은 스마트 포인터와 함께 작동하는 코드를 작성할 수 있도록 해줍니다.

2.1. 포인터를 따라가서 값 얻기

  • *(역참조 연산자)가 보통의 참조자에 대해 동작하는 방식을 살펴봅시다.
  • 보통의 참조자는 포인터의 한 종류이고, 포인터에 대해 생각하는 방법 하나는 어딘가에 저장된 값을 가리키는 화살표처럼 생각하는 것
fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
  • 숫자와 숫자에 대한 참조자를 비교하는 것은 이 둘이 서로 다른 타입이므로 허용되지 않습니다.
  • *를 사용하여 해당 참조자를 따라가서 그것이 가리키고 있는 값을 얻어내야 합니다.

2.2. Box<T>를 참조자처럼 사용하기

  • 참조자: 데이터의 주소를 가리킨다 + 데이터를 대여한다
  • Box<T>에 사용된 역참조 연산자는, 예제 15-6의 참조자에 사용된 역참조 연산자와 동일한 방식으로 기능

2.3. 자체 스마트 포인터 정의하기

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
  • x는 Copy 트레이트를 구현한 i32 타입이기 때문에, MyBox::new(x)를 호출할 때 값이 복사됩니다.
    • Copy된 값은 새로운 위치에 동일한 값을 가지게 되고, 원래 변수 x는 여전히 유효하고 동일한 값을 유지합니다.
  • Copy 트레이트를 구현하지 않은 타입
    • 힙 메모리에 데이터를 저장하는 타입들
      • String / Vec / HashMap<K, V>
      • Box<T> / Rc<T> / Mutex<T>
    • 값이 다른 변수에 할당되면 소유권이 이동하게 됩니다.
    • 이로 인해 원래 변수를 더 이상 사용할 수 없게 되어, 소유권이 한 번에 하나의 변수에만 유지되도록 보장됩니다.

2.4. Deref 트레이트를 구현하여 임의의 타입을 참조자처럼 다루기

  • 어떤 트레이트를 구현하기 위해서는
    • 그 트레이트가 요구하는 메서드에 대한 구현체를 제공해야 합니다.
  • 표준 라이브러리가 제공하는 Deref 트레이트는 deref라는 이름의 메서드 하나를 구현하도록 요구하는데,
    • 이 함수는 self를 빌려와서 내부 데이터의 참조자를 반환
  • .0이 튜플 구조체의 첫 번째 값에 접근한다는 것을 상기하세요.
  • Deref 트레이트가 없으면 컴파일러는 오직 & 참조자들만 역참조할 수 있습니다.
  • *y에 들어서면 러스트 뒤편에서는 실제로 아래와 같은 코드가 동작
    • *(y.deref())
  • deref 메서드가 값의 참조자를 반환하고, *(y.deref())에서의 괄호 바깥의 일반 역참조가 여전히 필요한 이유는 소유권 시스템과 함께 작동시키기 위해서입니다.
  • 만일 deref 메서드가 값의 참조자 대신 값을 직접 반환했다면, 그 값은 self 바깥으로 이동할 것입니다.
    • 위의 경우 혹은 역참조 연산자를 사용하는 대부분의 경우에서는 MyBox<T> 내부의 값에 대한 소유권을 얻으려는 것이 아닙니다.

2.5. 함수와 메서드를 이용한 암묵적 역참조 강제 변환

  • Deref 트레이트의 deref 메서드가 자동으로 호출되는 시점은 아래 2가지
    • *(역참조 연산자)가 호출될 때
    • 함수의 인수로 &참조자가 호출될 때 (역참조 강제 변환)
  • 역참조 강제 변환 (deref coercion) 은 Deref를 구현한 어떤 타입의 참조자다른 타입의 참조자로 바꿔줍니다.
    • 예를 들어, 역참조 강제 변환은 &String을 &str로 바꿔줄 수 있는데,
      • 이는 String의 Deref 트레이트 구현이 그렇게 &str을 반환하도록 했기 때문
  • 역참조 강제 변환은 러스트가 함수와 메서드의 인수에 대해 수행해 주는 편의성 기능이고,
    • Deref 트레이트를 구현한 타입에 대해서만 동작합니다.
  • 이는 어떤 특정한 타입값에 대한 참조자를 함수 혹은 메서드의 인수로 전달하는데
    • 이 함수나 메서드의 정의에는 그 매개변수 타입이 맞지 않을 때 자동으로 발생
  • 일련의 deref 메서드 호출이 인수로 제공한 타입을 매개변수로서 필요한 타입으로 변경해 줌
fn hello(name: &str) {
    println!("Hello, {name}!");
}
  • 인수로 넣어진 타입에 대해 Deref 트레이트가 정의되어 있다면, 러스트는 해당 타입을 분석하고 Deref::deref를 필요한 만큼 사용하여 매개변수 타입과 일치하는 참조자를 얻을 것입니다. - Deref::deref가 추가되어야 하는 횟수는 컴파일 타임에 분석되므로, 역참조 강제 변환의 이점을 얻는 데에 관해서 어떠한 런타임 페널티도 없습니다!

2.6. 역참조 강제 변환이 가변성과 상호작용하는 법

  • Deref 트레이트를 사용하여 불변 참조자에 대한 *를 오버라이딩하는 방법과 비슷한 방식으로,
  • DerefMut 트레이트를 사용하여 가변 참조자에 대한 * 연산자를 오버라이딩
  • 중요
    • T: Deref<Target=U>일 때 &T에서 &U로
    • T: DerefMut<Target=U>일 때 &mut T에서 &mut U로
    • T: Deref<Target=U>일 때 &mut T에서 &U로
  • 러스트는 가변 참조자를 불변 참조자로 강제할 수도 있습니다.
    • 하지만 그 역은 불가능하며, 불변 참조자는 가변 참조자로 결코 강제되지 않을 것입니다.
  • 대여 규칙에 의거하여, 가변 참조자가 있을 경우에는 그 가변 참조자가 해당 데이터에 대한 유일한 참조자여야 합니다.

3. Drop 트레이트로 메모리 정리 코드 실행하기

  • 스마트 포인터 패턴에서 중요한 트레이트 그 두 번째는 Drop인데, 이는 어떤 값이 스코프 밖으로 벗어나려고 할 때 무슨 일을 할지 커스터마이징하게끔 해줍니다.
  • 어떠한 타입이든 Drop 트레이트를 구현할 수 있고, 이 코드가 파일이나 네트워크 연결 같은 자원 해제에 사용되게 할 수 있습니다.
  • 스마트 포인터에 대한 맥락에서 Drop을 소개하는 이유
    • Drop 트레이트의 기능이 스마트 포인터를 구현할 때 거의 항상 이용되기 때문
    • 예를 들어 Box<T>가 버려질 때는 이 박스가 가리키고 있는 힙 공간의 할당을 해제
  • 러스트에서는 값이 스코프 밖으로 벗어날 때마다 실행되는 특정 코드를 지정할 수 있고, 컴파일러가 이 코드를 자동으로 삽입해 줄 것
  • Drop 트레이트는 drop이라는 이름의 메서드 하나를 구현해야 하는데,
    • 이 메서드는 self에 대한 가변 참조자를 매개변수로 갖습니다.
    • drop 메서드 = 소멸자 (destructor)
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("my stuff"),
    };
    let d = CustomSmartPointer {
        data: String::from("other stuff"),
    };
    println!("CustomSmartPointers created.");
}
CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!
  • 변수들은 만들어진 순서의 역순으로 버려짐

3.1. std::mem::drop 으로 값을 일찍 버리기

  • 가끔은 어떤 값을 일찍 정리하고 싶을 때도 있습니다.
  • 한 가지 예는 락을 관리하는 스마트 포인터를 이용할 때입니다:
    • 강제로 drop 메서드를 실행하여 락을 해제해서, 같은 스코프의 다른 코드에서 해당 락을 얻도록 하고 싶을 수도 있지요.
  • c.drop() 처럼, 명시적 호출이 불가능
  • std::mem::drop 함수Drop 트레이트에 있는 drop 메서드와는 다릅니다.
    • std::mem::drop 함수는 프렐루드에 구현되어 있음!
fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    drop(c);
    println!("CustomSmartPointer dropped before the end of main.");
}

4. Rc<T>, 참조 카운트 스마트 포인터

  • Rc: Reference Count
  • Rc<T>
    • 복수 소유권을 가능하게 하는 참조 카운팅 타입
    • 소유자의 개수를 계속 추적하고, 더 이상 소유자가 없으면 데이터를 정리
  • 하나의 값이 여러 개의 소유자를 가질 수 있는 경우도 있습니다.
    • 예를 들어,
      • 그래프 데이터 구조에서 여러 에지가 동일한 노드를 가리킬 수도 있고,
      • 그 노드는 개념적으로 해당 노드를 가리키는 모든 에지에 의해 소유됨
  • Rc<T> 타입은 어떤 값의 참조자 개수를 계속 추적하여 해당 값이 계속 사용 중인지를 판단합니다.
  • 만일 어떤 값에 대한 참조자가 0개라면 이 값의 메모리 정리를 하더라도 유효하지 않은 참조자가 발생하지 않을 수 있습니다.
  • Rc<T>를 거실의 TV라고 상상해 봅시다.
    • 한 사람이 TV를 보러 들어올 때 TV를 켭니다.
    • 다른 사람들은 거실로 들어와서 TV를 볼 수 있습니다.
    • 마지막 사람이 거실을 나선다면, TV는 더 이상 사용되고 있지 않으므로 끕니다.
  • Rc<T>는 오직 싱글스레드 시나리오용이라는 점을 주의

4.1. Rc<T>를 사용하여 데이터 공유하기

  • 위 코드 컴파일 안되는 이유?
    • Cons 배리언트는 자신이 들고 있는 데이터를 소유하므로,
    • b 리스트를 만들 때 a는 b 안으로 이동되어 b의 소유가 됩니다.
    • 그다음 c를 생성할 때 a를 다시 사용하려 할 경우는 허용되지 않는데, 이미 a가 이동되었기 때문

  • Box<T>의 자리에 Rc<T>를 이용하는 형태로 List의 정의를 바꾸겠습니다.
    • Rc::new 를 하면 -> Rc<List> 가 생성됨
  • b를 만들 때는 a의 소유권을 얻는 대신, a를 가지고 있는 Rc<List>를 클론할 것인데,
    • 이는 참조자의 개수를 하나에서 둘로 증가시키고
    • a와 b가 Rc<List> 안에 있는 데이터의 소유권을 공유하도록 해줍니다.
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}

4.2. Rc<T>를 클론하는 것은 참조 카운트를 증가시킵니다

  • Rc::clone를 호출하여 참조 카운트를 증가시켜야 했던 것과 달리
    • 참조 카운트를 감소시키기 위해 어떤 함수를 호출할 필요는 없습니다:
    • Rc<T> 값이 스코프 밖으로 벗어나면, Drop 트레이트의 구현체가 자동으로 참조 카운트를 감소시킵니다.
  • Rc<T>불변 참조자를 통하여 읽기 전용으로 프로그램의 여러 부분에서 데이터를 공유하도록 해줍니다.

5. RefCell<T>와 내부 가변성 패턴

  • 내부 가변성 (interior mutability)
    • 어떤 데이터에 대한 불변 참조자가 있을 때라도, 데이터를 변경할 수 있게 해주는 러스트의 디자인 패턴
    • 보통 이러한 동작은 대여 규칙에 의해 허용되지 않습니다.
  • 안전하지 않은 코드는 이 규칙들을 지키고 있는지에 대한 검사를
    • 컴파일러에게 맡기는 대신
    • 수동으로 하는 중임을 컴파일러에게 알립니다
  • 컴파일러는 대여 규칙을 준수함을 보장할 수 없을지라도, 우리가 이를 런타임에 보장할 수 있는 경우라면 내부 가변성 패턴을 쓰는 타입을 사용할 수 있습니다.
    • 여기에 포함된 unsafe 코드는 안전한 API로 감싸져 있고,
    • 바깥쪽 타입은 여전히 불변

5.1. RefCell<T>으로 런타임에 대여 규칙 집행하기

  • Rc<T>와는 다르게, RefCell<T> 타입은 가지고 있는 데이터에 대한 단일 소유권
  • 참조자와 Box<T>를 이용할 때,
    • 대여 규칙의 불변성은 컴파일 타임에 집행됩니다.
    • 컴파일 타임의 대여 규칙 검사는 아래 2가지 장점
      • 개발 과정에서 에러를 더 일찍 잡을 수 있다는 점,
      • 그리고 이 모든 분석이 사전에 완료되기 때문에 런타임 성능에 영향이 없다
  • RefCell<T>를 이용할 때,
    • 이 불변성은 런타임에 집행됩니다.
    • 특정 메모리 안정성 시나리오가 허용된다는 장점
    • 여러분의 코드가 대여 규칙을 준수한다는 것을 (컴파일러는 이해하거나 보장할 수 없지만) 여러분이 확신하는 경우 유용
  • Rc<T>와 유사하게, RefCell<T>은 싱글스레드 시나리오 내에서만 사용 가능

-Rc<T>는 동일한 데이터에 대해 복수 소유자를 가능하게 합니다;

  • Box<T>RefCell<T>은 단일 소유자만 갖습니다.
  • Box<T>는 컴파일 타임에 검사 되는 불변 혹은 가변 대여를 허용합니다;
    • Rc<T>는 오직 컴파일 타임에 검사 되는 불변 대여만 허용합니다;
    • RefCell<T>런타임에 검사되는 불변 혹은 가변 대여를 허용합니다.
  • RefCell<T>런타임에 검사 되는 가변 대여를 허용하기 때문에,
    • RefCell<T>이 불변일 때라도 RefCell<T> 내부의 값을 변경할 수 있습니다.

5.2. 내부 가변성: 불변값에 대한 가변 대여

  • 불변값 내부의 값을 변경하는 것이 내부 가변성 패턴
  • TODO
  • 하지만, 어떤 값이 자신의 메서드 내부에서는 변경되지만 다른 코드에서는 불변으로 보이게 하는 것이 유용한 경우가 있습니다.
    • 그 값의 메서드 바깥쪽 코드에서는 값을 변경할 수 없을 것입니다.
  • RefCell<T>을 이용하는 것이 내부 가변성의 기능을 얻는 한 가지 방법이지만,
    • RefCell<T>이 대여 규칙을 완벽하게 피하는 것은 아닙니다:
  • 컴파일러의 대여 검사기는 이러한 내부 가변성을 허용하고, 대신 대여 규칙은 런타임에 검사 됩니다.
    • 만일 이 규칙을 위반하면, 컴파일러 에러 대신 panic!을 얻을 것입니다.

5.2.1. 내부 가변성에 대한 용례: 목 객체

  • 테스트 중 종종 프로그래머는 어떤 타입 대신 다른 타입을 사용하게 되는데, 이러한 자리 표시형 타입을 테스트 더블 (test double) 이라고 합니다.
    • 목 객체 (mock object) 는 테스트 더블의 특정한 형태
  • 우리의 라이브러리는
    • 어떤 값이 최댓값에 얼마나 근접했는지를 추적하고
    • 어떤 메시지를 언제 보내야 할지에 대한 기능만 제공
  • 이 라이브러리를 사용하는 애플리케이션이
    • 메시지를 전송하는 것에 대한 메커니즘을 제공할 예정입니다:
    • 이 애플리케이션은 메시지를 애플리케이션 내에 집어넣거나, 이메일을 보내거나, 문자 메시지를 보내거나, 혹은 그 밖의 것들을 할 수 있습니다.
  • 라이브러리는 그런 자세한 사항을 알 필요가 없습니다.
    • 필요한 모든 것은 우리가 제공하게 될 Messenger라는 이름의 트레이트를 구현하는 것
  • 아래 코드는 컴파일 에러가 남
  • 메시지를 추적하기 위해서 MockMessenger를 수정할 수가 없는데,
    • 그 이유는 send 메서드가 self의 불변 참조자를 가져오기 때문
  • 또한 에러 메시지가 제안하는 &mut self를 대신 사용하라는 것도 받아들일 수 없는데,
    • 그렇게 되면 send의 시그니처가 Messenger 트레이트의 정의에 있는 시그니처와 맞지 않게 될 것이기 때문

  • send 메서드의 구현부에서 첫 번째 매개변수는 여전히 self의 불변 대여 형태인데,
    • 이는 트레이트의 정의와 일치합니다.
  • self.sent_messages의 RefCell<Vec<String>>에 있는 borrow_mut를 호출하여
    • RefCell<Vec<String>> 내부 값, 즉 벡터에 대한 가변 참조자를 얻습니다.
    • borrow_mut: RefMut<T> 반환
      • RefMut<T> 는 스마트 포인터
  • 내부 벡터 안에 몇 개의 아이템이 있는지 보기 위해서
  • RefCell<Vec<String>>borrow를 호출하여 벡터에 대한 불변 참조자를 얻습니다.
  • borrow: Ref<T> 반환
    • Ref<T> 는 스마트 포인터

5.2.2. RefCell<T>로 런타임에 대여 추적하기

  • RefCell<T>는 현재 활성화된 Ref<T>와 RefMut<T> 스마트 포인터들이 몇 개나 있는지 추적
    • RefCell<T>는 어떤 시점에서든
      • 여러 개의 불변 대여 혹은
      • 하나의 가변 대여를 가질 수 있도록 만들어 줍니다.
    • 위 규칙을 위반하면, 컴파일 에러를 내는 것이 아니라, 런타임에 panic!을 일으킬 것
  • 아래코드는 런타임 시점에 패닉 일으킴
    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));
        }
    }
  • 여러분의 코드는 컴파일 타임이 아닌 런타임에 대여를 추적하는 결과로 -> 약간의 런타임 성능 페널티를 초래할 것입니다.
  • 하지만 RefCell<T>를 이용하는 것은
    • 오직 불변값만 허용된 컨텍스트 안에서 사용하는 중에, 본 메시지를 추적하기 위해서 스스로를 변경할 수 있는 목 객체 작성을 가능하게 해 줍니다.

5.2.3. Rc<T>RefCell<T>를 조합하여 가변 데이터의 복수 소유자 만들기

  • Rc<T>
    • 복수 소유권을 가능하게 하는 참조 카운팅 타입
    • 소유자의 개수를 계속 추적하고, 더 이상 소유자가 없으면 데이터를 정리
    • 불변 접근만 허용
  • RefCell<T>를 들고 있는 Rc<T>를 가지게 되면,
    • 가변이면서 동시에 복수의 소유자를 갖는 값을 얻을 수 있는 것이죠!
  • Rc<T>가 오직 불변의 값만을 가질 수 있기 때문에, 일단 이것들을 만들면 리스트 안의 값들을 변경하는 것은 불가능했습니다.
  • RefCell<T>를 추가하여 이 리스트 안의 값을 변경하는 능력을 얻어봅시다.
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))

6. 순환 참조는 메모리 누수를 발생시킬 수 있습니다

  • 메모리 누수 (memory leak)
    • 뜻하지 않게 해제되지 않는 메모리
  • 러스트의 메모리 안정성 보장은 메모리 누수 (memory leak)를 생성하기 어렵게 만들지만, 불가능하게 만드는 것은 아닙니다.
  • Rc<T>RefCell<T>를 사용하면 러스트에서 메모리 누수가 허용되는 것을 알 수 있습니다:
    • 즉 아이템들이 서로를 순환 참조하는 참조자를 만드는 것이 가능합니다.
    • 이는 메모리 누수를 발생시키는데, 그 이유는
      • 순환 고리 안의 각 아이템의 참조 카운트는 결코 0이 되지 않을 것이고,
      • 그러므로 값들은 버려지지 않을 것이기 때문입니다.

6.1. 순환 참조 만들기

  • a를 수정하여 이것이 Nil 대신 b를 가리키도록 하였는데, 이렇게 순환이 만들어집니다.
  • 이는 tail 메서드를 사용하여 a에 있는 RefCell<Rc<List>>로부터 참조자를 얻어오는 식으로 이루어졌는데, 이것을 link라는 변수에 넣었습니다.
    • 그다음 RefCell<Rc<List>>의 borrow_mut 메서드를 사용하여
      • Nil 값을 가지고 있는 Rc<List> 내부의 값을 b의 Rc<List>로 바꾸었습니다.

  • main의 끝에서 러스트는 b를 버리는데, 이는 b의 Rc<List> 참조 카운트를 2에서 1로 줄입니다.
  • Rc<List>가 힙에 보유한 메모리는 이 시점에서 해제되지 않을 것인데,
    • 그 이유는 참조 카운트가 0이 아닌 1이기 때문입니다.
  • 그런 다음 러스트는 a를 버리고,
    • 이는 마찬가지로 a의 Rc<List> 인스턴스가 가진 참조 카운트를 2에서 1로 줄입니다.
  • 이 인스턴스의 메모리 또한 버려질 수 없는데, 왜냐하면 이쪽의 Rc<List> 인스턴스도 여전히 무언가를 참조하기 때문입니다.

  • 순환 참조를 만드는 것은 쉽게 이루어지지는 않지만, 불가능한 것도 아닙니다.
  • 만일 여러분이 Rc<T> 값을 가지고 있는 RefCell<T>
    • 혹은 그와 유사하게 내부 가변성 및 참조 카운팅 기능이 있는 타입들의 중첩된 조합을 사용한다면,
      • 여러분이 직접 순환을 만들지 않음을 보장해야 합니다;
    • 이 순환을 찾아내는 것을 러스트에 의지할 수는 없습니다.
  • 순환 참조를 피하는 또 다른 해결책은 데이터 구조를 재구성하여
    • 어떤 참조자는 소유권을 갖고 어떤 참조자는 그렇지 않도록 하는 것입니다.
  • 결과적으로 몇 개의 소유권 관계와 몇 개의 소유권 없는 관계로 이루어진 순환을 만들 수 있으며,
    • 소유권 관계들만이 값을 버릴지 말지에 관해 영향을 주게 됩니다.

6.2. 순환 참조 방지하기: Rc<T>Weak<T>로 바꾸기

  • Rc::clone을 호출하는 것은 Rc<T> 인스턴스의 strong_count를 증가시키고,
    • Rc<T> 인스턴스는 자신의 strong_count가 0이 된 경우에만 제거되는 것을 보았습니다.
    • 강한 참조는 Rc<T> 인스턴스의 소유권을 공유할 수 있는 방법
  • Rc::downgrade에 Rc<T>의 참조자를 넣어서 호출하면
    • Rc<T> 인스턴스 내의 값을 가리키는 약한 참조 (weak reference) 를 만드는 것도 가능합니다.
    • Rc<T> 인스턴스의 weak_count를 1 증가시킵니다.
    • Rc::downgrade를 호출하면 Weak<T> 타입의 스마트 포인터를 얻게 됩니다.
    • 약한 참조는 소유권 관계를 표현하지 않고,
      • 약한 참조의 개수는 Rc<T> 인스턴스가 제거되는 경우에 영향을 주지 않습니다.
  • 약한 참조가 포함된 순환 참조는 그 값의 강한 참조 개수를 0으로 만드는 순간 깨지게 되기 때문에,
    • 순환 참조를 일으키지 않게 될 것입니다.

  • Rc<T> 타입은 strong_count와 유사한 방식으로 weak_count를 사용하여 Weak<T> 참조가 몇 개 있는지 추적
  • Weak<T>가 참조하고 있는 값이 이미 버려졌을지도 모르기 때문에, Weak<T>가 가리키고 있는 값으로 어떤 일을 하기 위해서는
    • 그 값이 여전히 존재하는지를 반드시 확인해야 합니다.
    • 이를 위해 Weak<T>upgrade 메서드를 호출하는데,
      • 이 메서드는 Option<Rc<T>>를 반환할 것입니다.
        • 만일 Rc<T> 값이 아직 버려지지 않았다면 Some 결과를 얻게 될 것이고
        • Rc<T> 값이 버려졌다면 None 결괏값을 얻게 될 것입니다.

6.2.1. 트리 데이터 구조 만들기: 자식 노드를 가진 Node

  • Node가 자기 자식들을 소유하도록 하고, 이 소유권을 공유하여 트리의 각 Node에 직접 접근할 수 있도록 하고 싶습니다. Rc<Node> 타입의 값이 되도록 정의하였습니다.
  • 또한 어떤 노드가 다른 노드의 자식이 되도록 수정하려고,RefCell<T>로 감싼 children을 갖도록 하였습니다.
  • branch로부터 branch.children를 통하여 leaf까지 접근할 수 있게 되었지만,
  • leaf에서부터 branch로 접근할 방법은 없습니다.
  • 그 원인은 leaf가 branch에 대한 참조자를 가지고 있지 않고 이들 간의 연관성을 알지 못하기 때문입니다.
  • leaf에게 branch가 자신의 부모임을 알려주고 싶습니다.

6.2.2. 자식에서 부모로 가는 참조자 추적하기

  • leaf의 부모를 다시 한번 출력할 때는 branch를 가지고 있는 Some 배리언트를 얻게 될 것입니다:
    • 이제 leaf는 자기 부모에 접근할 수 있습니다!
  • leaf를 출력할 때 예제 15-26에서와 같이 궁극적으로 스택 오버플로우로 끝나버리는 그 순환 문제도 피하게 되었습니다;
  • Weak<Node> 참조자는 (Weak)로 출력됩니다:

6.2.3. strong_count와 weak_count의 변화를 시각화하기

  • 참조 카운트와 값 버리기를 관리하는 모든 로직은 Rc<T>Weak<T>, 그리고 이들의 Drop 트레이트에 대한 구현부에 만들어져 있습니다.
  • 자식과 부모의 관계가 Weak<T> 참조자로 있어야 함을 Node의 정의에 특정함으로써,
    • 여러분은 순환 참조와 메모리 누수를 만들지 않으면서
    • 자식 노드를 가리키는 부모 노드 혹은 그 반대의 것을 만들 수 있습니다.

profile
새로운 것이 들어오면 이미 있는 것과 충돌을 시도하라.

0개의 댓글