[Rust] 참조와 빌림
포스트
취소

[Rust] 참조와 빌림


Title


개요

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 매개변수의 소유권 반환에 관한 예제

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

    let (s2, len) = calculate_length(s1);

    println!("'{}'의 길이는 {}입니다.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();  // len()은 String의 길이를 반환한다

    (s, length)
}

위 코드에서 calculate_length 함수를 호출한 후에도 String을 사용하고 싶다면, 호출된 함수에서 String을 반환해야 한다. 이렇게 해야 하는 이유는 Stringcalculate_length로 넘어가면서 소유권이 이동되었기 때문이다. 여기서, String의 참조를 넘겨줌으로써 해당 값을 계속 사용하게 할 수 있다. 참조는 메모리 상의 특정 주소를 가리키며, 그 주소에 있는 데이터에 접근할 수 있게 해주는데, 이 데이터는 다른 변수가 소유하고 있다. 포인터와 달리 참조는 사용되는 동안 항상 유효한 타입의 값을 가리킨다는 것이 보장된다.

이제 객체를 넘기는 대신 참조를 매개변수로 사용하는 calculate_length 함수의 정의 및 사용 방법을 살펴보자:

파일명: src/main.rs

1
2
3
4
5
6
7
8
9
10
11
fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("'{}'의 길이는 {}이다.", s1, len);
}

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

변수를 선언하고 함수의 반환값을 정의할 때 튜플을 사용하던 코드는 이제 보이지 않는다. calculate_length 함수에 &s1을 전달할 때, 그리고 그 함수의 매개변수 타입으로 String 대신 &String을 사용할 때 이 앰퍼샌드(&) 기호가 중요한 역할을 한다. 이 기호는 참조를 나타내며, 값을 소유하지 않고도 그 값을 참조할 수 있음을 의미한다. 다음은 이 개념을 도식화한 그림이다.

refandbor00

[그림 1-1] &String sString s1을 가리키는 다이어그램


참고: 참조를 나타내는 &와는 반대되는 개념으로 역참조가 있다. 역참조는 * 연산자를 사용해서 이루어진다.


이제 함수 호출 과정에 대해 더 자세히 알아보자:

1
2
3
let s1 = String::from("hello");

let len = calculate_length(&s1);

위 코드에서는 &s1을 통해 s1의 값을 소유하지 않으면서도 참조를 만들 수 있다. 그래서 참조를 더 이상 사용하지 않더라도, 참조가 가리키고 있는 값은 메모리에서 해제되지 않는다.

함수 정의에서도 & 기호를 사용해서 매개변수 s가 참조임을 명시한다. 주석 내용을 참고하며 아래 코드를 살펴보자:

1
2
3
4
5
6
// `calculate_length` 함수는 `String`의 참조를 매개변수로 받고 길이를 반환한다.
fn calculate_length(s: &String) -> usize { // s는 String에 대한 참조
    s.len()
} // 여기서 `s`의 범위가 종료되지만,
  // `s`가 가리키는 값은 해제되지 않는다.
  // 이는 `s`에게 소유권이 없기 때문이다.

변수 s의 범위는 다른 함수 매개변수의 범위와 동일하다. 그러나 참조가 가리키는 값은 s의 사용이 종료될 때 해제되지 않는다. 이는 s가 해당 값을 소유하고 있지 않기 때문이다. 실제 값의 복사본을 만들어 소유권을 전달하는 대신에 참조를 매개변수로 사용하는 함수는 소유권을 반환할 필요가 없다.

참조를 만드는 이 과정을 빌림이라고 한다. 물건을 소유하고 있는 사람으로부터 그것을 빌려 사용한 후 사용이 종료되면 그것을 반납하는 것과 유사하다.

만약 빌린 값을 변경하려고 할 경우, 어떤 결과가 발생할까?

파일명: src/main.rs

아래 코드는 컴파일되지 않는다.

1
2
3
4
5
6
7
8
9
10
11
// 빌린 값에 변경을 가하려고 할 때

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

    change(&s);
}

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

이 코드를 컴파일하려고 하면 다음과 같은 에러 메시지가 나타난다:

1
2
3
4
5
6
7
8
9
10
11
12
$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: `*some_string`을 가변으로 빌리고자 하였으나, `&` 참조를 통해서는 불가능하다.
 --> src/main.rs:8:5
  |
7 | fn change(some_string: &String) {
  |                        ------- 도움말: 가변 참조로 변경하고자 한다면 `&mut String`을 고려하라.
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `some_string``&` 참조이기 때문에, 가변으로 빌리는 것이 허용되지 않는다.

에러에 대한 자세한 정보를 원하신다면, rustc --explain E0596을 실행하라.
error: 발생한 에러로 인하여 ownership 프로젝트의 컴파일을 완료할 수 없다.

변수가 기본적으로 불변임을 원칙으로 하듯, 참조도 불변이다. 따라서 참조를 통해 값을 변경하는 것은 허용되지 않는다.


가변 참조

위 코드를 수정하여 빌린 값을 변경할 수 있도록 하려면, 가변 참조를 사용해야 한다.

파일명: src/main.rs

1
2
3
4
5
6
7
8
9
fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

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

이 코드에서는 s를 변경 가능하게(mut) 선언하였다. 또한 change 함수를 호출할 때 &mut s를 사용하여 가변 참조를 생성하고, 함수의 매개변수 타입도 some_string: &mut String으로 변경하여 가변 참조를 받을 수 있도록 하였다. 이제 change 함수 내에서 some_string의 값을 수정할 수 있다.

가변 참조에는 중요한 제약이 있다: 한 번에 하나의 가변 참조만이 허용된다. 즉, 동일한 데이터에 대해 여러 개의 가변 참조를 동시에 생성하는 것은 허용되지 않는다.

다음 코드에서는 s에 대한 두 개의 가변 참조를 생성하려고 시도하고 있다:

파일명: src/main.rs

아래 코드는 컴파일되지 않는다.

1
2
3
4
5
6
    let mut s = String::from("hello");

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

    println!("{}, {}", r1, r2);

이 코드를 실행하면 다음과 같은 에러 메시지가 나타난다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: `s`를 한 번에 두 번 이상 가변으로 빌릴 수 없다.
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ 여기서 첫 번째 가변 빌림이 발생한다.
5 |     let r2 = &mut s;
  |              ^^^^^^ 여기서 두 번째 가변 빌림이 발생한다.
6 |
7 |     println!("{}, {}", r1, r2);
  |                        -- 첫 번째 빌림은 여기서 사용된다.

자세한 정보를 원한다면, rustc --explain E0499을 실행하라.
error: 발생한 에러로 인하여 ownership 프로젝트의 컴파일을 완료할 수 없다.

이 에러 메시지는 s 변수가 동시에 두 개의 가변 참조를 가질 수 없음을 알려준다. 첫 번째 가변 참조는 r1에서, 두 번째는 r2에서 나타난다.

한 데이터에 대해 동시에 여러 가변 참조를 만들지 못하게 하는 이 규칙은 데이터의 변형을 체계적으로 통제한다. 다른 언어들은 개발자가 원할 때 언제든지 데이터를 변형할 수 있게 하지만, Rust는 이를 제한함으로써 특별한 장점을 제공한다. 이러한 제한은 Rust가 컴파일 단계에서 데이터 경쟁을 사전에 차단할 수 있게 해준다. 데이터 경쟁은 다음 세 조건이 충족될 때 발생하는 문제이다:

  • 두 개 이상의 포인터가 동시에 같은 데이터에 접근한다.
  • 이 중 최소 한 개의 포인터가 데이터에 쓰기를 시도한다.
  • 데이터 접근을 조율하는 동기화 메커니즘이 없다.

데이터 경쟁은 예측 불가능한 동작을 초래하며, 런타임 때 이를 발견하고 수정하기 어렵다. Rust는 이러한 문제를 컴파일 과정에서 데이터 경쟁을 일으킬 수 있는 코드를 배제함으로써 해결한다.

또한, 중괄호를 사용해 새로운 범위를 만들어주면 동시에 일어나지 않는 여러 가변 참조를 허용할 수 있다.

1
2
3
4
5
6
7
let mut s = String::from("hello");

{
    let r1 = &mut s;
} // 여기서 r1의 스코프가 끝나므로, 문제 없이 새 참조를 만들 수 있다.

let r2 = &mut s;

Rust에서는 가변 참조와 불변 참조를 함께 사용할 때도 비슷한 규칙을 적용한다.

아래 코드는 컴파일되지 않는다.

1
2
3
4
5
6
7
let mut s = String::from("hello");

let r1 = &s; // 이것은 괜찮다
let r2 = &s; // 이것도 문제가 없다
let r3 = &mut s; // 여기서 큰 문제가 발생한다

println!("{}, {}, 그리고 {}", r1, r2, r3);

이때 발생하는 에러 메시지는 다음과 같다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: `s`에 대해 불변으로 빌린 상태에서 가변으로 빌릴 수 없다.
 --> *src/main.rs*:6:14
  |
4 |     let r1 = &s; 
  |              -- 불변 빌림 발생
5 |     let r2 = &s; 
6 |     let r3 = &mut s; 
  |              ^^^^^^ 가변 빌림 발생
7 |
8 |     println!("{}, {}, 그리고 {}", r1, r2, r3);
  |                                -- 불변 빌림이 여기서 사용됨

이 에러에 대한 자세한 정보를 보려면 `rustc --explain E0502`를 확인한다.
error: 이전의 에러 때문에 `ownership` 컴파일에 실패하였다.

우리는 동시에 한 값에 대한 불변 참조와 가변 참조를 가질 수 없다는 것을 알 수 있다.

불변 참조를 사용하는 개발자들은 그 값이 예상치 못하게 변경되지 않기를 원한다. 여러 불변 참조가 있어도 문제가 없는 이유는, 이들이 데이터를 읽기만 하기 때문에 서로 방해하지 않기 때문이다.

참조의 유효 범위란 그 참조가 시작되는 지점부터 마지막으로 사용되는 지점까지를 말한다. 예를 들어, 불변 참조가 끝나고 난 뒤에(println!), 가변 참조가 시작되는 아래 코드는 문제없이 컴파일될 수 있다.

1
2
3
4
5
6
7
8
9
let mut s = String::from("hello");

let r1 = &s; // 이건 괜찮다
let r2 = &s; // 이것도 문제없다
println!("{} 그리고 {}", r1, r2);
// 여기서 r1과 r2는 더 이상 사용되지 않는다

let r3 = &mut s; // 이것도 괜찮다
println!("{}", r3);

불변 참조인 r1r2는 가변 참조 r3가 생기기 전, 그들이 마지막으로 쓰인 println! 이후에 유효 범위가 끝난다. 이 두 범위는 겹치지 않으므로 이 코드는 문제없이 작동한다. 컴파일러는 참조가 더 이상 쓰이지 않는 시점을 알고 있기 때문에 범위가 어디서 끝나는지 알 수 있다.

Rust에서 빌림 관련 에러가 나타날 때마다, 이것이 런타임 전에 컴파일러가 잠재적인 문제를 지적하고 정확히 어떤 문제인지 알려주는 것임을 기억하자. 이를 통해 개발자는 왜 데이터가 자신이 예상한 것과 다른지 궁금해할 필요가 없다.


허상 참조

포인터를 사용하는 프로그래밍 언어에서는, 메모리를 사용하다가 더 이상 필요 없어서 해제했는데도 그 메모리를 가리키는 포인터를 그대로 두면 문제가 생길 수 있다. 이런 상황에서 그 메모리가 다른 용도로 다시 쓰이게 되면, 허상 참조라고 불리는 오류가 발생할 수 있다. 이는 포인터가 실제로는 없어진 메모리를 가리키고 있기 때문이다. 하지만 Rust에서는 이런 일이 발생하지 않는다. Rust의 컴파일러는 프로그램을 만들 때 이런 허상 참조가 생기지 않도록 철저히 확인한다. 즉, 데이터를 참조하고 있다면, 그 데이터가 필요한 동안은 사라지지 않도록 컴파일러가 관리한다.

Rust가 컴파일 과정에서 이런 허상 참조를 어떻게 막는지 직접 실험해보는 것도 도움이 될 것이다.

파일명: src/main.rs

아래 코드는 컴파일되지 않는다.

1
2
3
4
5
6
7
8
9
fn main() {
    let reference_to_nothing = dangle();
}

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

    &s
}

에러 메시지는 다음과 같다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> *src/main.rs*:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: 이 함수의 반환 타입에는 빌린 값이 포함되어 있지만, 빌릴 수 있는 값이 존재하지 않는다
help: consider using the `'static` lifetime
  |
5 | fn dangle() -> &'static String {
  |                 +++++++

`rustc --explain E0106`을 실행하면 이 에러에 대한 자세한 정보를 얻을 수 있다.
error: `ownership`을 컴파일할 수 없다. 이전 에러 때문이다.

아직 설명하지 않은 생명주기에 대한 기능을 이 에러 메시지가 언급하고 있다. 생명주기 부분을 제외하고 본다면, 이 메시지는 코드에 문제가 있는 주된 이유를 담고 있다:

1
이 함수의 반환 타입에는 빌린 값이 포함되어 있지만, 빌릴 수 있는 값이 존재하지 않는다

dangle 함수 내부에서 무슨 일이 일어나는지 살펴보자.

파일명: src/main.rs

1
2
3
4
5
6
fn dangle() -> &String { // dangle은 String에 대한 참조를 반환한다

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

    &s // 여기서 String s에 대한 참조를 반환한다
} // 이 지점에서 s는 유효 범위를 벗어나고, 메모리에서 해제된다. 이는 위험하다!

sdangle 함수 내부에서 만들어졌으므로, 함수가 끝나면 s는 자동으로 해제된다. 그런데 우리는 s의 참조를 반환하려고 한다. 이는 참조가 사라진 String을 가리키게 되는 것이고, Rust는 이런 코드를 허용하지 않는다.

문제를 해결하는 방법은 String을 직접 반환하는 것이다:

1
2
3
4
5
fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

이 코드는 소유권을 이동시키기 때문에, String이 해제되는 일이 없어서 안전하게 작동한다.


참조의 기본 규칙들

참조를 사용할 때 기억해야 할 기본적인 규칙들을 다시 살펴보자:

  • 한 번에 하나의 가변 참조나 여러 개의 불변 참조 중 하나만 가질 수 있다. 즉, 데이터를 변경할 수 있는 가변 참조가 있으면 그 시점에는 다른 어떤 참조도 같은 데이터에 있어서는 안 된다. 반면에 데이터를 변경하지 않는 불변 참조는 여러 개 있어도 괜찮다.
  • 모든 참조는 항상 유효해야 한다. 이는 참조가 가리키는 데이터가 그 참조가 존재하는 동안에는 항상 존재해야 함을 의미한다.

이제 우리는 참조의 또 다른 형태인 슬라이스에 대해 알아볼 차례이다.


출처: rust-lang book

이 포스트는 저작권자의 CC BY-NC-ND 4.0 라이센스를 따릅니다.

[Rust] 소유권이란?

[Rust] 슬라이스 타입