Programming/Rust

쪼잔한 Rust 4.2. 참조와 대여

동건 2018. 8. 14. 21:06

Chapter 4.2. 참조와 대여

이전 글에서 tuple을 사용했던 예제 4-5는 이런 문제가 있었다: String의 ownership이 calculate_length 함수 안으로 이동했기 때문에, 함수 호출이 끝난 후에 그 String을 다시 사용하기 위해서 ownership을 다시 내보내야 했던 것이다.

아래는 함수가 parameter로서 그 값의 ownership을 가져가는 대신, 참조값(reference)만 가져가는 방법을 보여준다.

// Filename: src/main.rs

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

첫째로 알아둘 것은, 변수 선언과 함수 반환값을 위한 tuple 코드가 모두 사라졌다는 것이고, 둘째로는 calculate_length&s1을 건내줬고, 이 함수의 정의에서도 String 대신 &String을 취한다는 것이다.

& 표시는 reference를 의미하는데, ownership을 가져가는 대신 그 주소만을 가리킬 수 있게 하는 것이다. 그림 4-5는 이를 설명한다.


The Rust book Figure 4-5: &String s pointing at String s1그림 4-5: String s1을 가리키는 &String의 도표


참고: &를 이용해서 reference를 사용하는 것의 반대를 dereference라고 하고, dereference 연산자 *를 통해 사용할 수 있다. 챕터 8에서 이 dereference 연산자의 사용 예를 볼 것이고, 챕터 15에서 자세하게 이야기해 볼 것이다.

다시 함수 호출 부분을 보겠다.

let s1 = String::from("hello");

let len = calculate_length(&s1);

&s1 syntax을 통해서 우리는 s1의 값을 소유허지 않고 그 주소를 가리키고만 있는 reference를 생성할 수 있다. 이 reference는 값의 소유권을 가져가지 않기 때문에, reference가 scope 밖으로 나가더라도 그 값은 제거되지 않는다.

마찬가지로, 함수의 signature도 &를 사용해서 parameter s의 타입이 reference라고 명시한다. 코드에 설명을 조금 덧붙여 보자.

fn calculate_length(s: &String) -> usize { // s는 String의 reference이다
    s.len()
} // 여기서 s는 scope 밖으로 나온다. 하지만 s가 가리키는 String의 ownership을 
  // 가지고 있지 않으므로, 아무 일도 일어나지 않는다.

변수 s가 살아있는 scope는 함수 parameter의 scope와 같다. 하지만 s가 scope을 바깥을 나갈 때, s는 ownership을 갖고 있지 않기 때문에 s가 가리키는 것을 없애지 않는다.함수가 실제 값 대신 reference를 parameter로 가지면, ownership을 애초에 가져온 적이 없기 때문에 ownership을 되돌려 줄 필요가 없다.

그래서 함수 parameter로 reference를 사용하는 것을 대여(borrowing)한다고 부른다. 우리네 인생에서 누가 뭔가를 소유하고 있다면 나는 그것을 빌릴 수 있는 것과 같다. 빌려서 할 일이 끝나면, 당연하게도 다시 되돌려드려야 한다.

그래서 우리가 빌려간 것을 변경하려고 한다면 어떻게 될까? 아래 예제 4-6을 보자. 스포일러 경고: 에러가 난다!

예제 4-6: 빌려온 값을 바꾸려는 시도

// Filename: src/main.rs
fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

아래는 에러 내용이다:

error[E0596]: cannot borrow immutable borrowed content `*some_string` as mutable
 --> error.rs:8:5
  |
7 | fn change(some_string: &String) {
  |                        ------- use `&mut String` here to make mutable
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ cannot borrow as mutable

Rust의 변수가 기본적으로 immutable했던 것처럼 reference도 마찬가지다. 우리는 reference가 가리키는 것을 변경하도록 허락받지 않은 것이다.


가변적인 참조

예제 4-6의 에러를 약간의 코드 수정을 통해 고칠 수 있다:

// Filename: src/main.rs
fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

우선 smut 하도록 바뀌었다. 그리고 &mut s를 통해 mutable reference를 만들었고, 함수는 some_string: &mut String을 통해 mutable reference를 받아들일 수 있다.

하지만 mutable reference는 하나의 큰 제약점이 있다: 한 scope에서 하나의 데이터 당 오직 하나의 mutable reference만을 가질 수 있다. 따라서 아래 에러를 유발하는 코드이다:

// Filename: src/main.rs
let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

에러 내용은 다음과 같다:

error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> borrow_twice.rs:5:19
  |
4 |     let r1 = &mut s;
  |                   - first mutable borrow occurs here
5 |     let r2 = &mut s;
  |                   ^ second mutable borrow occurs here
6 | }
  | - first borrow ends here

위 제약점은 데이터 변경(mutation)을 굉장히 통제된 방식으로 가능하게 한다. 대부분의 다른 언어들은 바꾸고 싶은 것은 무엇이든지 바꿀 수 있게 해주기에, 이러한 방식은 새로운 Rustacean들을 힘들게 하곤 한다.

이 제약점의 이점을 말해보자면, Rust가 컴파일 시점에서 data race를 막아준다는 점이다. Data race는 race condition과 비슷한 의미를 가진 용어로, 아래 세 가지 행동이 일어날 때를 말한다.

  • 2개 이상의 포인터가 동시에 같은 데이터에 접근한다.
  • 그 중 최소 하나의 포인터가 그 데이터에 쓰기 작업을 하기 위해 사용된다.
  • 데이터의 접근을 동시에 하게 할 수 있는 방법이 존재하지 않는다.

Data race는 예기치 못한 행동을 초래하고, 런타임에서 문제를 진단하고 고치고자 하는 것이 어려울 수 있다. Rust는 이런 문제가 애초에 일어나는 것 자체를 막는다, 왜냐하면 data race가 일어나는 코드는 컴파일 조차 되지 않기 때문이다!

항상 그랬듯이, 새로운 scope를 생성하기 위해서는 {}를 사용한다. 그리고 새로운 scope는 여러 개의 mutable reference를 가능하게 해준다:

let mut s = String::from("hello");

{
    let r1 = &mut s;

} // r1이 여기서 scope 밖으로 나가므로, 이제 새로운 reference를 만들어도 된다.

let r2 = &mut s;

Mutable reference와 immutable reference를 같이 쓰는 것에 있어서도 비슷한 규칙이 있다. 아래는 에러가 나는 코드이다:

let mut s = String::from("hello");

let r1 = &s; // no problem
let r2 = &s; // no problem
let r3 = &mut s; // BIG PROBLEM

에러가 이렇게 나온다:

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as
immutable
 --> borrow_thrice.rs:6:19
  |
4 |     let r1 = &s; // no problem
  |               - immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |                   ^ mutable borrow occurs here
7 | }
  | - immutable borrow ends here

Whew! Immutable reference를 가지고 있다면, 이와 동시에 mutable reference를 가지고 있을 수 없다. Immutable reference를 사용한다는 것은 그 데이터가 갑자기 어딘가에서 바뀔 것이라 예상하고 있지 않는다는 것이다! 또한 mutable reference의 경우와 반대로 여러 개의 immutable reference를 갖고 있어도 되는 것을 볼 수 있는데, 이는 reference들이 데이터를 읽는 서로의 동작에 영향을 전혀 끼칠 수 없기 때문이다.

지금까지 본 에러들이 때때로 우리에게 좌절을 줄 수도 있다. 하지만 그럴 때마다 Rust 컴파일러가 런타임 시점이 아닌 컴파일 시점에서 잠재적인 버그를 일찍 지적해주는 것이고, 그 버그가 어떤 문제 때문인지 똑똑하게 알려주는 것임을 기억하길 바란다. Rust를 사용하면 당신이 예상했던 데이터가 왜 안 나오는지 일일이 추적할 필요가 없다.


주인없는 참조

Pointer를 사용하는 언어를 쓸 때면, 주인을 잃어버린 포인터(dangling pointer)를 실수로 만들기가 쉽다. Dangling pointer는 이미 다른 곳에 할당되었을 수 있는 메모리 주소를 가리키는 reference를 의미하는데, 그 메모리의 pointer가 살아있는 와중에 일정 메모리를 제거해버리면 이런 경우가 발생한다. Rust 컴파일러는 반면에 reference는 절대 dangling reference가 되도록 두지 않는다: 어떤 데이터의 reference가 존재한다면, 컴파일러는 reference가 사라지기 전까지는 해당 데이터가 scope 밖으로 나가지 않는 지를 보장해 줄 것이다.

Dangling reference를 한 번 만들어보자. 그러면 Rust가 컴파일 에러로 이를 막을 것이다:

// Filename: src/main.rs
fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

에러가 이렇게 나온다:

error[E0106]: missing lifetime specifier
 --> dangle.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is
  no value for it to be borrowed from
  = help: consider giving it a 'static lifetime

위 에러 메세지는 아직 다루지 않은 개념인 lifetime을 언급한다. Lifetime은 챕터 10에서 이야기할 것이다. 하지만 lifetime과 관련된 내용을 제외하고도 에러 메세지에서는 코드의 문제에 대한 핵심을 말해주고 있다:

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from.

위 dangling code의 각 단계에서 무슨 일이 일어나는 지 조금 더 상세하게 보겠다:

fn dangle() -> &String { // dangle은 String reference를 내보낸다

    let s = String::from("hello"); // s는 새로운 String이다

    &s // String reference인 s를 내보낸다
} // 여기서 s는 scope 밖으로 나가므로, drop되고, 그 메모리는 청소된다.
  // Danger!

sdangle 함수 안에서 만들어졌기 때문에, dangle 함수가 끝나면 s의 메모리 할당도 해제된다. 하지만 우리는 여전히 그 reference를 반환하려고 했다. 그 reference는 이미 죽은 String을 가리키고 있음을 의미한다. 이는 절대 좋은 일이 아니다! Rust는 이런 일이 일어나도록 우리를 내버려두지 않는다.

문제의 해답은 String을 직접 반환하는 것이다:

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

이렇게 하면 어떤 문제도 없이 잘 작동한다. Ownership이 옮겨질 것이고, 어떤 메모리도 해제되지 않는다.

참조의 규칙

이번 섹션에서 reference에 대해 이야기했던 것을 정리해보겠다:

  • 어떤 시점에서든지 하나의 mutable reference를 가지거나, 여러 개의 immutable reference를 가질 수 있지만, 이 두 가지를 동시에 가질 수는 없다.
  • Reference가 가리키는 데이터는 항상 살아있어야 한다. (References must always be valid)

다음 섹션에서는 또 다른 종류의 reference인 slice의 개념에 대해 살펴보겠다.

반응형