</>PatchNote
목록으로

2024-07-13

HSPACE Rust 특강 #4 — 레퍼런스

RustHSPACE 2024ReferencesLifetime

소유권 회차의 자연스러운 다음 순서. 소유권을 넘기지 않고 값을 쓰려면 빌려야 하고, 그 도구가 레퍼런스다.

왜 레퍼런스가 필요한가

이 예제가 문제를 정확히 보여준다.

type Table = HashMap<String, Vec<String>>;

fn show(table: Table) {
    for (artist, works) in table {   // table을 소비하며 순회
        println!("works by {artist}:");
        for work in works {
            println!("  {work}");
        }
    }
}

fn main() {
    let mut table = Table::new();
    // ... 채워 넣기 ...

    show(table);

    // assert_eq!(table["Gesualdo"][0], "many madrigals");
    //            ^^^^^ 에러: table은 show로 이동됨
}

show 는 그냥 출력만 하는데 테이블을 먹어치웠다. for (artist, works) in tableHashMap 을 값으로 순회하면서 소유권까지 가져갔기 때문이다.

고치는 방법은 & 하나다.

fn show(table: &Table) {
    for (artist, works) in table {   // &Table을 순회 → 원소도 레퍼런스
        println!("works by {artist}:");
        for work in works {
            println!("  {work}");
        }
    }
}

show(&table);
assert_eq!(table["Gesualdo"][0], "many madrigals");   // 잘 동작한다

&Table 을 순회하면 (&String, &Vec<String>) 이 나오므로 아무것도 소비하지 않는다. 값을 읽기만 하는 함수는 레퍼런스를 받는 게 기본이라는 걸 이 예제 하나로 각인시켰다.

레퍼런스 다루기

Rust의 레퍼런스는 C++ 레퍼런스와 생김새가 비슷하지만 결정적으로 다르다.

1. 역참조가 명시적이다

let x = 10;
let r = &x;         // &x는 x에 대한 공유 레퍼런스
assert!(*r == 10);  // r을 명시적으로 역참조

let mut y = 32;
let m = &mut y;     // &mut y는 y에 대한 가변 레퍼런스
*m += 32;           // 역참조해서 y의 값을 바꾼다
assert!(*m == 64);

C++ 레퍼런스는 대상과 문법적으로 구별되지 않는다. Rust는 * 를 쓰게 해서 "지금 레퍼런스를 다루고 있는가, 값을 다루고 있는가" 를 코드에 드러낸다.

다만 . 연산자와 메서드 호출은 자동으로 역참조한다.

let anime_ref = &aria;
assert_eq!(anime_ref.name, "Aria: The Animation");
assert_eq!((*anime_ref).name, "Aria: The Animation");   // 위와 동일

let mut v = vec![1973, 1968];
v.sort();           // 암묵적으로 v를 가변 빌림
(&mut v).sort();    // 동일하지만 장황함

v.sort() 가 내부적으로 &mut v 를 만든다는 걸 알고 나니, "왜 여기서 빌림 에러가 나지?" 하던 상황들이 설명됐다. 메서드 호출 자체가 빌림이다.

2. 레퍼런스는 대상을 바꿀 수 있다

C++ 레퍼런스는 한 번 묶이면 다른 대상을 가리킬 수 없다. Rust 레퍼런스는 그냥 값이라 재대입이 된다.

let x = 10;
let y = 20;
let mut r = &x;

if b { r = &y; }    // 이제 r은 x 대신 y를 가리킨다

assert!(*r == 10 || *r == 20);

3. 레퍼런스의 레퍼런스도 만들 수 있다

let point = Point { x: 1000, y: 729 };
let r:   &Point   = &point;
let rr:  &&Point  = &r;
let rrr: &&&Point = &rr;

assert_eq!(rrr.y, 729);   // . 연산자가 필요한 만큼 역참조한다

. 연산자가 몇 겹이든 자동으로 벗겨준다.

4. 비교 연산자도 대상을 비교한다

let x = 10;
let y = 10;
let rx = &x;
let ry = &y;
let rrx = &rx;
let rry = &ry;

assert!(rrx == rry);              // 최종 대상끼리 비교
assert!(rx == ry);                // 가리키는 값이 같고
assert!(!std::ptr::eq(rx, ry));   // 주소는 다르다

// assert!(rx == rrx);   // 에러: &i32 vs &&i32 — 타입이 다르다
assert!(rx == *rrx);     // 이건 OK

주소를 비교하고 싶으면 std::ptr::eq 를 명시적으로 써야 한다. C에서 == 가 포인터 비교인 것과 정반대의 기본값이고, 이쪽이 실수를 덜 만든다.

5. 임의의 표현식에도 레퍼런스를 만들 수 있다

fn factorial(n: usize) -> usize {
    (1..=n).product()
}

let r = &factorial(6);

// 산술 연산자는 레퍼런스를 한 겹 꿰뚫어 본다
assert_eq!(r + &1009, 1729);

&factorial(6) 은 이름 없는 임시 변수를 만들고 그걸 가리킨다. 이 임시 변수는 레퍼런스가 살아 있는 동안 유지된다. C++에서 임시 객체의 수명 연장 규칙을 외워야 했던 부분이, 여기서는 빌림 검사기가 알아서 처리한다.

레퍼런스 안전성

레퍼런스에서 진짜 중요한 건 절대 죽은 값을 가리키지 않는다는 보장이다.

지역 변수 빌리기

{
    let r;
    {
        let x = 1;
        r = &x;         // 에러: `x` does not live long enough
    }
    assert_eq!(*r, 1);  // 나쁨: x가 있던 메모리를 읽는다
}

C였다면 조용히 컴파일되고, 운이 좋으면 값이 남아 있고 운이 나쁘면 쓰레기를 읽는다. Rust는 r 의 수명이 x 의 수명보다 길다는 사실만으로 거부한다.

레퍼런스 반환하기

// v는 원소가 하나 이상이어야 한다
fn smallest(v: &[i32]) -> &i32 {
    let mut s = &v[0];
    for r in &v[1..] {
        if *r < *s { s = r; }
    }
    s
}

let s;
{
    let parabola = [9, 4, 1, 0, 1, 4, 9];
    s = smallest(&parabola);
}
assert_eq!(*s, 0);   // 에러: 드롭된 배열의 원소를 가리킨다

smallest 의 시그니처는 수명 생략 규칙에 의해 fn smallest<'a>(v: &'a [i32]) -> &'a i32 로 읽힌다. 즉 반환된 레퍼런스는 입력 슬라이스만큼만 산다. parabola 가 안쪽 블록에서 끝나므로 s 도 거기까지다. 함수 본문이 아니라 시그니처만 보고 검사가 이루어진다는 점이 중요하다.

레퍼런스를 담은 구조체

구조체가 레퍼런스를 필드로 가지면 수명을 적어야 한다.

struct S<'a, 'b> {
    x: &'a i32,
    y: &'b i32,
}

let x = 10;
let r;
{
    let y = 20;
    {
        let s = S { x: &x, y: &y };
        r = s.x;
    }
}
println!("{r}");

수명 매개변수를 하나('a)만 쓰면 xy 의 수명이 같아야 해서 위 코드가 거부된다. 둘로 나누면 각각 다른 수명을 가질 수 있고, s.x 를 밖으로 빼내는 게 허용된다. 수명 매개변수를 몇 개 둘지가 API 설계 결정이라는 걸 여기서 배웠다.

공유 vs 변경

레퍼런스 규칙의 핵심을 한 문장으로 줄이면 이렇다.

공유 레퍼런스는 여러 개, 가변 레퍼런스는 하나. 그리고 둘은 공존할 수 없다.

// Part 1
let mut x = 10;
let r1 = &x;
let r2 = &x;                    // OK: 공유 빌림은 여러 개 가능
x += 10;                        // 에러: 빌려진 상태라 대입 불가
let m = &mut x;                 // 에러: 불변으로 빌려져 있어 가변 빌림 불가
println!("{r1}, {r2}, {m}");    // 여기서 쓰이므로 빌림이 여기까지 산다

let mut y = 20;
let m1 = &mut y;
let m2 = &mut y;                // 에러: 가변 빌림은 두 개 이상 불가
let z = y;                      // 에러: 가변으로 빌려져서 y를 쓸 수 없음
println!("{m1}, {m2}, {z}");

주목할 건 x += 10 도 에러라는 점이다. 소유자 본인도 빌려준 동안에는 값을 바꿀 수 없다. 공유 레퍼런스가 살아 있는 동안 그 값은 전체가 얼어붙는다.

재빌림(reborrow) 규칙도 같은 원리로 흐른다.

// Part 2
let mut w = (107, 109);
let r = &w;
let r0 = &r.0;      // OK: 공유에서 공유로 재빌림
let m1 = &mut r.1;  // 에러: 공유에서 가변으로는 재빌림 불가
println!("{r0}");

// Part 3
let mut v = (136, 139);
let m = &mut v;
let m0 = &mut m.0;  // OK: 가변에서 가변으로 재빌림
*m0 = 137;
let r1 = &m.1;      // OK: 가변에서 공유로, 그리고 m0와 겹치지 않음
v.1;                // 에러: 다른 경로를 통한 접근은 여전히 금지
println!("{r1}");

Part 3이 재미있다. m.0m.1 은 겹치지 않으므로 동시에 빌릴 수 있다. 빌림 검사기가 구조체를 통째로 보는 게 아니라 필드 단위로 추적한다는 뜻이다. 하지만 m 을 통해 빌려준 상태에서 v 로 직접 접근하는 건 여전히 막힌다.

이 규칙이 왜 이렇게 엄격한가? 공유 = 불변이라는 등식이 성립하면 얻는 게 많다.

  • 데이터 경합이 정의상 불가능하다
  • 이터레이터를 도는 중에 컬렉션이 재할당되어 레퍼런스가 무효화되는 일이 없다 (C++의 iterator invalidation)
  • 컴파일러가 앨리어싱 없음을 알기 때문에 더 공격적으로 최적화할 수 있다

오브젝트의 바다와 맞서기

강의 마지막 슬라이드의 제목이 인상적이었다. 객체들이 서로를 마구 가리키는 "오브젝트의 바다(sea of objects)"는 GC 언어에서 흔한 구조인데, Rust는 이걸 어렵게 만든다.

처음엔 이게 언어의 결함처럼 느껴졌다. 하지만 강의를 따라가면서 관점이 바뀌었다. "아무 데서나 아무거나 가리킬 수 있는" 구조는 사실 누가 무엇을 소유하고 언제 바뀌는지 아무도 모르는 구조이기도 하다. Rust는 그걸 못 하게 막는 대신, 소유권 트리를 먼저 설계하도록 강제한다.

정말로 그래프 구조가 필요하면 방법은 있다.

  • Rc<RefCell<T>> — 공유 소유권 + 내부 가변성(빌림 검사가 런타임으로)
  • 인덱스 기반 — 노드를 Vec 에 넣고 포인터 대신 usize 인덱스로 참조. Rust로 그래프나 트리를 짤 때 실제로 가장 많이 쓰이는 방식이다
  • Weak — 순환 참조를 끊을 때

정리

  • 읽기만 하는 함수는 &T 를 받는다. 소유권을 먹지 않는다
  • 역참조는 명시적(*)이지만 . 과 비교 연산자는 자동으로 벗겨준다
  • 레퍼런스 안전성은 함수 시그니처의 수명만으로 검사된다
  • 공유 레퍼런스가 살아 있는 동안 그 값은 소유자에게도 불변이다
  • 빌림 검사는 필드 단위로 이루어진다

다음 편은 표현식 — Rust에서 ifmatch 가 값을 만든다는 사실이 코드 모양을 어떻게 바꾸는지.