</>PatchNote
목록으로

2024-07-07

HSPACE Rust 특강 #3 — 소유와 이동

RustHSPACE 2024OwnershipMemory

첫 회차에서 소유권을 한 문단으로 훑고 지나갔다면, 이번엔 한 회차를 통째로 쓴다. "값을 대입했을 뿐인데 왜 원본을 못 쓰지?"라는 질문이 이 회차에서 정리됐다.

소유권은 트리를 이룬다

Rust에서 값의 소유자는 하나뿐이고, 소유자가 스코프를 벗어나면 값이 드롭된다.

fn print_padovan() {
    let mut padovan = vec![1, 1, 1];  // 여기서 할당

    for i in 3..10 {
        let next = padovan[i - 3] + padovan[i - 2];
        padovan.push(next);
    }

    println!("P(1..10) = {:?}", padovan);
}  // 여기서 드롭

padovan 은 (포인터, 길이, 용량)으로 이루어진 스택 값이고, 실제 원소는 힙에 있다. 함수가 끝나면 padovan 이 드롭되면서 힙 버퍼도 함께 해제된다. free() 를 부르지 않아도, 어디서 해제될지는 코드 모양만 보면 알 수 있다.

중요한 건 소유권이 중첩된다는 점이다.

struct Person { name: String, birth: i32 }

let mut composers = Vec::new();
composers.push(Person { name: "Palestrina".to_string(), birth: 1525 });
composers.push(Person { name: "Dowland".to_string(),    birth: 1563 });
composers.push(Person { name: "Lully".to_string(),      birth: 1632 });

여기서 소유 관계는 이렇게 생겼다.

composers (Vec<Person>)
 ├─ Person { name: String → 힙 버퍼, birth }
 ├─ Person { name: String → 힙 버퍼, birth }
 └─ Person { name: String → 힙 버퍼, birth }

composers 를 드롭하면 각 Person 이 드롭되고, 각 Personname 이 드롭되면서 문자열 버퍼가 해제된다. 소유권은 그래프가 아니라 트리이고, 루트를 놓으면 그 아래가 전부 정리된다. C++의 RAII와 같은 아이디어지만, 여기서는 언어가 그 트리 구조를 강제한다.

이동 (Moves)

대부분의 언어에서 대입은 복사이거나 참조 공유다. Rust는 세 번째다: 이동.

let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
let t = s;   // 소유권이 t로 이동
let u = s;   // 에러: s는 이미 이동됨

값의 소유자가 하나여야 하므로, t = ss미초기화 상태로 만든다. 정말로 세 개의 벡터가 필요하면 복사 비용을 명시적으로 지불한다.

let t = s.clone();
let u = s.clone();

이 설계가 뜻하는 바는 깊은 복사가 코드에 보인다는 것이다. C++에서 값 대입이 조용히 힙 복사를 일으켜 성능이 새는 것과 반대다.

이동이 일어나는 자리들

대입만 이동이 아니다.

  • 변수에 대입 (let t = s;)
  • 함수에 인자로 전달
  • 함수에서 값 반환
  • 구조체나 벡터에 값을 넣기 (composers.push(person))

이미 값이 들어 있는 변수에 대입하면, 이전 값은 그 자리에서 드롭된다.

let mut s = "Govinda".to_string();
s = "Siddhartha".to_string();   // "Govinda"가 여기서 드롭됨

let mut s = "Govinda".to_string();
let t = s;                      // "Govinda"의 소유권이 t로
s = "Siddhartha".to_string();   // 드롭되는 것 없음 — s는 비어 있었으니까

두 번째 경우가 재미있다. s 는 이미 이동해서 비어 있으므로 재대입할 때 드롭할 게 없다. 컴파일러는 각 변수가 "지금 값을 갖고 있는지"를 추적하고 있다.

제어 흐름과 이동

분기나 반복이 끼면 컴파일러가 모든 경로에서 규칙이 성립하는지 본다.

let x = vec![10, 20, 30];

if c {
    f(x);   // OK: 여기서 x를 이동해도 되고
} else {
    g(x);   // OK: 여기서 이동해도 된다
}

h(x);       // 에러: 어느 경로로 왔든 x는 이미 이동됨
let x = vec![10, 20, 30];

while cond() {
    g(x);   // 에러: 첫 반복에서 이동되면 두 번째 반복에서는 미초기화
}

반복문 안에서 값을 이동시키려면, 매 반복마다 새 값을 채워 넣어야 한다.

let mut x = vec![10, 20, 30];

while f() {
    g(x);       // x를 이동
    x = h();    // 새 값을 넣어준다
}

e(x);

컴파일러가 "매 반복 시작 시점에 x 가 값을 갖고 있는가"를 검사하고 있는 것이다. 런타임 검사가 아니라 흐름 분석이라 비용이 0이다.

인덱스가 걸린 값은 이동할 수 없다

이건 진짜 자주 걸린다.

let mut v = Vec::new();
for i in 101..106 {
    v.push(i.to_string());
}

// let third = v[2];   // 에러: Vec의 인덱스에서 값을 꺼낼 수 없음

왜 안 될까? 만약 v[2] 를 이동시키면, 벡터는 "2번 자리가 비어 있는" 상태가 된다. 그런데 벡터에는 그런 상태를 표현할 방법이 없다. 나중에 드롭할 때 그 자리를 해제해야 하는지 알 수 없기 때문이다. 그래서 컬렉션에서 값을 꺼내려면 구멍을 남기지 않는 방법을 써야 한다.

// 1. 끝에서 뽑기
let fifth = v.pop().expect("vector empty!");
assert_eq!(fifth, "105");

// 2. 꺼내면서 마지막 원소를 그 자리에 채우기
let second = v.swap_remove(1);
assert_eq!(second, "102");

// 3. 다른 값으로 바꿔치기
let third = std::mem::replace(&mut v[2], "substitute".to_string());
assert_eq!(third, "103");

assert_eq!(v, vec!["101", "104", "substitute"]);

셋 다 "빈 자리를 만들지 않는다"는 원칙을 지킨다. std::mem::replace 는 특히 자주 쓰게 되는 도구다.

컬렉션 전체를 소비할 때는 그냥 값으로 순회하면 된다.

let v = vec!["liberté".to_string(), "égalité".to_string(), "fraternité".to_string()];

for mut s in v {    // v 전체가 루프로 이동
    s.push('!');
    println!("{s}");
}
// 여기서 v는 더 이상 사용할 수 없다

for s in &v (빌림), for s in &mut v (가변 빌림), for s in v (이동) 세 가지가 전부 다른 의미라는 걸 이때 확실히 정리했다.

Copy 타입: 이동의 예외

이동 규칙에는 예외가 있다.

// String 대입은 "이동"
let string1 = "somnambulance".to_string();
let string2 = string1;      // string1은 이제 못 씀

// i32 대입은 "복사"
let num1: i32 = 36;
let num2 = num1;            // num1은 여전히 유효

Copy 타입은 값을 넘겨도 원본이 남는다. 조건은 단순하다: 비트 단위 복사만으로 완전한 사본이 되어야 한다. 그래서 정수, bool, char, 부동소수점, 그리고 Copy 타입만으로 이루어진 튜플·배열이 Copy 다. String, Vec, Box 처럼 힙 자원을 소유하면 Copy 가 될 수 없다.

구조체와 열거체는 기본적으로 Copy 가 아니다. 필드가 전부 Copy 라도 그렇다.

struct Label { number: u32 }

fn print(l: Label) { println!("STAMP: {}", l.number); }

let l = Label { number: 3 };
print(l);
// println!("{}", l.number);   // 에러: 이동 후 사용

원하면 명시적으로 붙인다.

#[derive(Clone, Copy)]
struct Label { number: u32 }

let l = Label { number: 3 };
print(l);
println!("My label number is: {}", l.number);   // OK

기본값이 "이동"인 게 좋은 설계라고 생각하는 이유는, Copy 를 붙이는 게 공개 API의 약속이기 때문이다. 나중에 이 구조체에 String 필드를 추가하면 Copy 를 뗄 수밖에 없고, 그건 파괴적 변경이다. 그러니 처음부터 자동으로 붙여주지 않는 편이 낫다.

Rc와 Arc: 공유된 소유권

트리 구조로 표현되지 않는 관계도 있다. 여러 곳에서 같은 값을 소유해야 하고, 누가 마지막인지 컴파일 시점에 알 수 없는 경우다. 이때 참조 카운팅을 쓴다.

use std::rc::Rc;

let s: Rc<String> = Rc::new("shirataki".to_string());
let t: Rc<String> = s.clone();
let u: Rc<String> = s.clone();

assert!(s.contains("shira"));
assert_eq!(t.find("taki"), Some(5));
println!("{u} are quite chewy, almost bouncy, but lack flavor");

Rc::clone 은 문자열을 복사하지 않는다. 참조 카운트를 1 올릴 뿐이다. 마지막 Rc 가 드롭될 때 값이 해제된다.

대신 대가가 있다.

// Rc가 소유한 값은 불변이다
// s.push_str(" noodles");   // 에러

여러 소유자가 있는데 아무나 변경할 수 있으면 공유 규칙이 깨지므로, Rc<T>&T 만 준다. 안에 있는 값을 바꾸려면 Rc<RefCell<T>> 처럼 내부 가변성을 조합해야 하고, 그러면 빌림 검사가 런타임으로 옮겨간다(위반하면 패닉).

그리고 참조 카운팅의 고전적 함정: 순환 참조는 해제되지 않는다. Rc 가 서로를 가리키면 카운트가 0이 되지 않아 누수가 난다. 이때는 한쪽을 Weak 로 만든다. Rust가 막는 것은 메모리 안전 위반(use-after-free, 이중 해제)이지, 누수는 안전한 동작으로 분류된다는 점이 여기서 드러난다.

RcArc 의 차이는 카운터의 원자성뿐이다.

use std::sync::Arc;

let arc1 = Arc::new("Hyundai".to_string());
let arc2 = arc1.clone();

thread::spawn(move || {
    let _ = arc2.clone();   // OK
});

Rc 는 비원자적 카운터라 빠르지만 스레드를 넘길 수 없고(Send 미구현), Arc 는 원자적 카운터라 조금 느리지만 스레드 간 공유가 된다. 헷갈릴 일도 없다. Rc 를 스레드로 넘기려 하면 컴파일이 안 되기 때문이다.

정리

  • 소유권은 트리를 이루고, 루트가 드롭되면 아래가 전부 정리된다
  • 대입·전달·반환·컬렉션 삽입이 전부 이동이고, 깊은 복사는 .clone() 으로 드러난다
  • 제어 흐름이 갈라져도 "모든 경로에서 한 번만 이동"이 검사된다
  • 컬렉션에는 구멍을 낼 수 없으니 pop, swap_remove, mem::replace 를 쓴다
  • 비트 복사로 충분하면 Copy, 여러 소유자가 필요하면 Rc/Arc

다음 편은 레퍼런스 — 소유권을 넘기지 않고 값을 빌려 쓰는 쪽을 파고든다.