모두의 코드
Rust 의 레퍼런스들에 관한 규칙들

작성일 : 2023-09-08 이 글은 157 번 읽혔습니다.

이 글은 Rust 에서 레퍼런스들에 대해서 공부하면서 머리를 싸메던 저에게 한 줄기 빛과 같았던 을 읽고 제 나름대로 다시 정리하여서 쓴 글입니다.

Rust 에선 두 가지 종류의 레퍼런스를 정의할 수 있습니다. 하나는 참조하는 변수를 변경할 수 있는 변경 가능한(mutable) 레퍼런스고, 다른 하나는 참조하는 대상을 변경할 수 없는 변경 불가능(immutable) 한 레퍼런스 입니다. C++ 에서 오신 분들은 레퍼런스와 const 레퍼런스로 생각하시면 됩니다.

하지, Rust 의 레퍼런스와 C++ 의 레퍼런스는 큰 차이가 있는데, C++ 의 경우 아무런 제약 없이 어떤 변수에 대해서 임의로 const/non-const 레퍼런스들을 마음껏 정의하고 사용할 수 있지만, Rust 에선 mutable/immutable 레퍼런스를 사용하기 위해서는 몇 가지 중요한 규칙들을 지켜야 합니다. 이 글에서는 이 규칙들에 대해서 간단히 이야기 하고자 합니다.

Rust 에서 변경 가능한 레퍼런스를 만들기 위해서는 mut 키워드를 레퍼런스를 정의하면 됩니다. 예를 들어서

let mut x = 3;
let mrx = &mut x; // x 에 대한 mutable reference.
*mrx = 5;
println("{}", x); // '5'

위 처럼 &mut 키워드로 정의된 레퍼런스인 mrx 를 통해서 x 의 값을 변경할 수 있습니다.

반면에 아무 키워드 없이 정의된 레퍼런스는 참조하는 값을 변경할 수 없고 읽기 만 가능합니다.

let mut x = 3;
let rx = &x; // x 에 대한 immutable reference.
// *rx = 5; 불가능
println("{}", *rx); // '3'

각각의 레퍼런스를 언제 사용할 수 있는지는 아래 두 개의 규칙에 따라 작동합니다.

변경 불가능한 레퍼런스에 관한 규칙

어떤 변수의 immutable 레퍼런스의 라이프타임 안에서 해당 변수는 immutable 하고, immutable 한 레퍼런스만 만들 수 있다.

예를 들어서 아래와 같은 코드를 살펴봅시다.

struct S{}

fn main() {
    let mut x = S{};      
    let rx = &x;          // ---- rx 의 라이프타임 시작 
    &x;                   // 가능. x 의 immutable 한 레퍼런스 만들 수 있음.
    // x = S{};           // 불가능. x 는 immutable 하기 때문
    // let y = x;         // 불가능. 역시 x 는 immutable 하기 때문.
    // x;                 // 불가능. 암시적인 move.
    // let mrx = &mut x;  // 불가능. immutable 한 레퍼런스만 만들 수 있음.
    rx;                   // ---- rx 의 라이프타임 끝.
}

여기서 중요한 점은 위 규칙은 immutable 레퍼런스의 라이프타임 안에서만 적용된다는 점입니다. 예를 들어서 아래 코드를 봅시다.

struct S{}

fn main() {
    let mut x = S{};      
    let rx = &x;          // ---- rx 의 라이프타임 시작 

    // 만일 밑에서 rx 가 쓰이지 않는다면 rx 의 라이프타임은 여기서 끝난다.

    let mrx = &mut x;  // 가능. rx 의 라이프타임 밖이므로!
}

위 경우 mrx 밑으로 rx 가 쓰이지 않기 때문에 rx 의 라이프타임은 mrx 바로 위에서 끝나게 됩니다. 따라서 위 규칙을 위반하지 않고 x 의 mutable 레퍼런스인 mrx 를 정의할 수 있겠죠.

한 가지 중요한 점은 라이프타임 이란 것이 연속적인 영역이 아니라는 점입니다.

fn main() {
    let mut x = 10;
    let rx = &x; // rx 의 라이프타임

    if rand() > 1 { // (1)
        // rx 의 라이프타임 아님.
        let mrx = &mut x; 
        *mrx = 11;
    } else { // (2)
        println!("{}", rx); // rx 의 라이프타임
    }
}

위 경우, x 의 mutable 레퍼런스인 mrxrx 밑에 정의되었지만 위 코드는 성공적으로 컴파일 됩니다. 왜냐하면 컴파일러가 (1) 번 if 문 branch 를 택하게 되면 rx 가 더이상 쓰이지 않는다는 것을 알기 때문에 해당 영역을 rx 의 라이프타임에서 제외할 수 있습니다. 따라서 규칙을 위반하지 않고 x 의 mutable 레퍼런스를 정의할 수 있죠.

변경 가능한 레퍼런스에 관한 규칙

Mutable 레퍼런스를 정의하는 것은 참조하는 변수를 해당 레퍼런스의 라이프타임 동안 임시적으로 이동시킨 것과 같다.

변경 가능한 레퍼런스를 정의하는 것은 해당 변수를 그 레퍼런스로 잠시 동안 이동시킨 (move) 것과 같다고 생각하면 됩니다. 그리고, 레퍼런스의 라이프타임이 끝나게 되면 다시 레퍼런스에서 원래 변수로 돌아가게 됩니다. 예를 들어서

fn main() {
    let mut x = 1;
    let rx = &x;
    let mrx = &mut x;     // x 가 *mrx 로 이동했다고 보면 됩니다.
    // x;                 // 현재 x 가 mrx 로 이동된 상태이므로 x 를 사용할 수 없습니다.
    // rx;                // 여기서 rx 를 사용하게 되면 rx 의 라이프타임이 여기 까지 연장됩니다.
                          // 그러면 immutable 레퍼런스 규칙에 위배되죠 (mutable 레퍼런스가 정의되어 있으므로)
    mrx;                  // 이 다음 부터 mrx 의 라이프타임이 끝납니다.
    x;                    // 다시 x 를 사용할 수 있습니다.
}

Mutable 레퍼런스 &mut TCopy 를 구현하고 있지 않습니다. 따라서, 아래와 같은 = 는 기본적으로 move 로 구현됩니다.

fn main() {
    let mut x = 1;
    let mrx = &mut x;
    let mry = mrx;    // mrx 를 mry 로 이동.
    // mrx;           // 따라서 mrx 를 사용할 수 없다.
}

만일 mrx 를 사용하려고 한다면 아래와 예상했던 대로 이동된 값을 참조한다는 오류가 발생하게 됩니다.

다시 빌리기 (Reborrow)

그런데 mutable 레퍼런스가 다른 mutable 레퍼런스로 전달 될 때 항상 이동만 되는 것은 아닙니다. 아래와 같은 코드를 생각해봅시다.

fn take_mut(d: &mut i32) {
    *d += 1;
}

fn main() {
    let mut x = 1;

    let mrx = &mut x;
    take_mut(mrx); // mrx 가 d 로 이동이 되나?
    take_mut(mrx); // 만일 이동이 되었다면 mrx 를 사용할 수 없는데..

    println!("{}", x);
}

실행 해본다면 놀랍게도 컴파일 오류 없이

실행 결과

3

제대로 take_mutmrx 를 받아서 여러번 호출된 것을 확인할 수 있습니다. 만일 mutable 레퍼런스가 항상 이동만 되었다면 첫 번째 take_mut 호출에서 mrxtake_mut 의 인자인 d이동 되었을 것입니다. 그렇다면 take_mut 이후에서 mrx 를 사용할 수 없겠지요. 하지만 그 뒤에도 다시 take_mut 을 호출할 수 있었습니다.

이게 가능한 이유는 mutable 레퍼런스를 전달하는 두 번째 방식인 다시 빌리기 (Reborrow) 때문입니다. 위 경우 d*mrx다시 빌리게 되고, d 의 라이프타임이 끝나게 되면 x 의 소유권이 다시 원래 주인이였던 mrx 로 돌아가게 됩니다.

Rust 컴파일러는 어떤 mutable 레퍼런스가, 다른 mutable 레퍼런스로 정의된 레퍼런스로 전달이 된다면 mutable 레퍼런스를 이동 시키는 대신에 reborrow 를 수행합니다. 여기서 중요한 점은 다른 레퍼런스가 명시적으로 mutable 로 정의 되어 있어야 한다는 것입니다.

예를 들어서, 위 move 예제에서 mry&mut i32 로 명시적으로 타입이 정의되어 있지 않기 때문에 컴파일러가 그냥 이동을 수행하지만, take_mut 함수에서 d&mut i32 로 명시적으로 mutable 레퍼런스로 정의되어 있기 때문에 reborrow 가 수행이 되는 것입니다.

즉, take_mut(mrx)

take_mut(&mut *mrx);

로 변환되서 컴파일 됩니다. 기존 레퍼런스 (mrx) 가 역참조(dereference) 되고, 다시 그 값에 대한 새로운 mutable 레퍼런스가 생성이 되서 함수에 전달됩니다. 그리고 함수가 끝나게 되면 다시 소유권이 mrx 로 넘어오게 되죠.

만일 위 이동 예제에서 mrymrx 를 역참조 하게 된다면

fn main() {
    let mut x = 1;
    let mrx = &mut x;
    let mry = &mut *mrx; // mry 가 mrx 를 reborrow
    // let mry: &mut i32 = mrx; 도 됩니다.

    // mry 의 라이프타임 끝.

    mrx; // 따라서 mrx 를 다시 사용할 수 없다.
}

위 처럼 mry 의 라이프타임이 끝났을 때 mrx 를 다시 사용할 수 있게 됩니다.

위 기본적인 규칙들만 잘 기억을 한다면 Rust 에서 레퍼런스를 다룰 때 아마 큰 도움이 될 것이라 생각합니다. 그러면 오늘도 Rust 컴파일러와 고군분투를 벌이고 있는 여러분들을 응원하며 이 글을 마치도록 하겠습니다 :)

참고자료

다음 글들이 큰 도움이 되었습니다.

댓글이 3 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

    댓글을 불러오는 중입니다..