본문 바로가기

참조와 대여

값의 소유권을 넘기지 않는 불변·가변 참조를 사용하고 대여 규칙이 데이터 경쟁과 댕글링 참조를 막는 원리를 이해합니다.

소유권이 뭔가요?의 마지막 예제에서는 String이 calculate_length로 이동해 버리므로, 함수를 부른 뒤에도 그 String을 쓰려면 튜플로 돌려받아야 했습니다. 이 글에서는 그 대신 값의 참조(reference)를 넘기는 방법을 살펴봅니다. 참조는 다른 값이 있는 곳을 가리키되 그 값을 소유하지 않는 값으로, 참조를 통해 그 값에 접근할 수 있습니다. 포인터와 비슷하지만, 참조는 쓰이는 동안 언제나 해당 타입의 유효한 값을 가리킨다는 것을 컴파일러가 보장합니다.

참조로 값 넘기기

다음은 소유권을 넘기지 않고 참조를 받도록 calculate_length를 고친 코드입니다. 앞 글에서 튜플을 돌려주던 코드와 비교하면서, main의 호출 calculate_length(&s1)과 매개변수 타입 &String에 붙은 &를 보세요.

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

    let len = calculate_length(&s1);

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

fn calculate_length(s: &String) -> usize {
    s.len()
}
The length of 'hello' is 5.

변수 선언과 반환 값에 있던 튜플이 모두 사라졌습니다. calculate_length에 s1 대신 &s1을 넘기고, 함수 정의의 매개변수 타입도 String 대신 &String이 되었습니다. 이 앰퍼샌드 &가 참조를 나타내며, 값의 소유권을 가져오지 않고 그 값을 가리키게 합니다. 다음 그림은 &String 타입의 s가 String 타입의 s1을 가리키는 모습입니다.

String s1을 소유권 이동 없이 참조하는 &String s의 포인터 구조

&로 참조를 만드는 것의 반대, 곧 참조를 따라가 그 값을 꺼내는 것을 역참조(dereferencing)라고 하며, 역참조 연산자는 *입니다. 역참조는 벡터와 값 목록에서 몇 번 쓰고 Deref와 역참조 강제 변환에서 자세히 다룹니다.

&s1은 s1의 값을 가리키지만 소유하지는 않는 참조를 만듭니다. 소유하지 않으므로 참조를 다 써도 그 참조가 가리키는 값은 버려지지 않습니다. 함수의 시그니처에서도 &로 매개변수 s가 참조라는 것을 나타냅니다.

fn calculate_length(s: &String) -> usize { // s는 String의 참조
    s.len()
} // s가 스코프를 벗어나지만, s는 가리키는 값을 소유하지 않으므로
  // 그 String은 버려지지 않음

s가 유효한 범위는 다른 매개변수와 같지만, s가 소유권을 갖지 않으므로 s를 더 이상 쓰지 않아도 가리키는 값은 버려지지 않습니다. 함수가 값 대신 참조를 매개변수로 받으면, 처음부터 소유권을 받지 않았으므로 소유권을 돌려주려고 값을 반환할 필요도 없습니다. 이렇게 참조를 만드는 것을 대여(borrowing)라고 합니다. 현실에서 남의 물건을 빌려 쓰고 다 쓰면 돌려주듯이, 빌린 값은 내 것이 아닙니다.

그러면 빌린 값을 고치려 하면 어떻게 될까요? 다음 코드는 컴파일되지 않습니다.

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 `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
  |
help: consider changing this to be a mutable reference
  |
7 | fn change(some_string: &mut String) {
  |                         +++

변수가 기본으로 불변인 것처럼 참조도 기본으로 불변이어서, 참조를 통해 가리키는 값을 고칠 수 없습니다. 오류 메시지는 some_string이 & 참조라 그 값을 고치는 데 쓸 수 없다고 하고, 7번째 줄의 매개변수를 &mut String으로 바꾸라는 도움말을 줍니다.

가변 참조자

조금만 고치면 빌린 값을 바꿀 수 있습니다. 가변 참조(mutable reference)를 쓰면 됩니다.

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");
}

먼저 s를 mut으로 바꾸고, change를 부를 때 &mut s로 가변 참조를 만들어 넘기며, 함수도 some_string: &mut String으로 가변 참조를 받게 했습니다. 이렇게 하면 change가 빌린 값을 바꾼다는 것이 코드에 분명히 드러납니다.

가변 참조에는 큰 제약이 하나 있습니다. 어떤 값의 가변 참조가 있는 동안에는 그 값의 참조를 하나도 더 만들 수 없습니다. 다음 코드는 s의 가변 참조를 두 개 만들려 하므로 컴파일되지 않습니다.

src/main.rs (컴파일되지 않음)
fn main() {
    let mut s = String::from("hello");

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

    println!("{}, {}", r1, r2);
}
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 |
7 |     println!("{}, {}", r1, r2);
  |                        -- first borrow later used here

E0499는 s를 한 번에 두 번 넘게 가변으로 빌릴 수 없다는 오류입니다. 첫 가변 대여는 r1에 있고 println!에서 쓰일 때까지 유지되어야 하는데, 그 사이에 r2가 같은 값을 또 가변으로 빌리려 했습니다. 대부분의 언어는 언제든 값을 바꾸게 하므로 처음에는 답답하게 느껴지지만, 이 제약 덕분에 러스트는 데이터 경쟁(data race)을 컴파일할 때 막을 수 있습니다.

데이터 경쟁은 다음 세 가지가 함께 일어날 때 생기는 경쟁 상태(race condition)입니다. 둘 이상의 포인터가 같은 데이터에 동시에 접근하고, 그 가운데 하나 이상이 데이터에 쓰며, 접근을 동기화하는 장치가 없는 경우입니다. 데이터 경쟁은 정의되지 않은 동작을 일으키고, 실행 중에 원인을 찾아 고치기가 어렵습니다. 러스트는 데이터 경쟁이 생길 수 있는 코드를 아예 컴파일하지 않습니다.

중괄호로 새 스코프를 만들면, 가변 참조를 여러 개 쓰더라도 동시에 존재하지는 않게 할 수 있습니다.

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

    {
        let r1 = &mut s;
        r1.push_str("!");
    } // r1이 여기서 스코프를 벗어나므로 새 참조를 만들어도 됨

    let r2 = &mut s;

가변 참조와 불변 참조를 섞을 때도 비슷한 규칙이 적용됩니다. 다음 코드도 컴파일되지 않습니다.

src/main.rs (컴파일되지 않음)
fn main() {
    let mut s = String::from("hello");

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

    println!("{}, {}, and {}", r1, r2, r3);
}
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
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 |
8 |     println!("{}, {}, and {}", r1, r2, r3);
  |                                -- immutable borrow later used here

같은 값의 불변 참조가 있는 동안에는 가변 참조도 만들 수 없습니다. 불변 참조를 쓰는 쪽은 그 값이 도중에 바뀌리라고 생각하지 않기 때문입니다. 반면 불변 참조는 여러 개여도 괜찮습니다. 데이터를 읽기만 하는 쪽은 다른 쪽이 읽는 데 영향을 주지 않기 때문입니다.

참조가 유지되는 구간은 참조를 만든 곳부터 그 참조를 마지막으로 쓰는 곳까지입니다. 변수가 선언된 블록 전체가 아니라는 점이 중요합니다. 다음 코드는 불변 참조를 마지막으로 쓰는 첫 println! 뒤에 가변 참조를 만들므로 컴파일됩니다.

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

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    println!("{r1} and {r2}");
    // r1과 r2는 이 뒤로 쓰이지 않음

    let r3 = &mut s; // no problem
    println!("{r3}");

r1과 r2라는 이름은 블록 끝까지 남아 있지만, 두 불변 참조는 첫 println!에서 마지막으로 쓰였으므로 그 뒤로는 대여가 끝난 것으로 봅니다. 그래서 r3의 가변 대여와 겹치지 않습니다. 컴파일러는 참조가 스코프 끝보다 앞에서 더 이상 쓰이지 않는다는 것을 알아낼 수 있으며, 아래 그림이 이 과정을 보여 줍니다.

마지막 사용이 공유 대여와 가변 대여를 나눈다

r1/r2 공유 참조의 마지막 사용 뒤 활성 공유 접근이 끝나고 r3 가변 대여가 허용되는 직선 코드의 상태 전환이다. 블록 스코프가 남는 사실과 마지막 사용을 분리하며 뒤에서 공유 참조를 다시 사용하는 E0502는 별도 소스의 컴파일 거부로 표시한다.

같은 String의 공유 참조 r1과 r2가 마지막으로 사용된 뒤 가변 참조 r3를 만들 수 있지만, 공유 참조를 나중에 다시 사용하면 앞선 가변 대여가 거부되는 상태 흐름

BORROW LIVENESS · LAST USE

마지막 공유 참조 사용 뒤에 가변 대여할 수 있다

바인딩의 블록 스코프가 아니라, 제어 흐름에서 참조가 마지막으로 사용되는 지점이 다음 대여와 겹치는지를 봅니다.

공유 대여의 마지막 사용 이후 허용 경로와 조기 변경 거부 경로기존desktop의6노드5간선이다. 참조생성→공유대여활성→활성대여없음→가변대여활성→종료. 공유참조를 뒤에서 다시쓰는 대안 코드에서는 공유대여활성에서 E0502거부로 우회한다. 실패 경로는 성공 경로의 후속실행이 아니다. 마지막사용은 여기서 제시한 직선코드 조건이며 일반제어흐름 설명은 원본문에 유지된다.참조 생성r1, r2: &String공유 대여 활성읽기 · 재공유뒤의 사용까지 유지활성 대여 없음공유 참조 마지막 사용바인딩의 블록은 남음가변 대여 활성r3: &mut String독립 접근은 겹치지 않음가변 대여 종료r3의 마지막 사용 뒤겹치는 대여 거부공유 참조를 뒤에서 사용E0502 · 실행 전 거부완료&mut공유 참조를 재사용거부 경로는 별도 코드 조건허용 뒤에 실패를 실행하지 않음
공유 대여의 마지막 사용과 가변 대여 상태 r1과 r2의 공유 대여가 첫 println에서 마지막으로 사용되면 뒤의 r3 가변 대여가 허용되고, 공유 대여가 이후에도 사용되면 조기 가변 대여는 E0502로 거부됩니다. 마지막 사용&mut s마지막 사용 전 &mut s공유 대여 활성r1, r2: &String읽기·재공유 가능뒤의 사용까지 활성활성 대여 없음after println!공유 참조 마지막 사용바인딩 스코프는 유지가변 대여 활성r3: &mut Stringr3·파생 재대여 경로독립 접근은 겹치지 않음겹치는 접근 · 거부E0502공유 참조를 뒤에서 다시 사용공유 대여와 가변 대여가 겹침r3 마지막 사용 뒤 종료
  1. 공유 참조를 만듭니다.

    r1 = &s와 r2 = &s는 같은 값을 읽는 공유 대여입니다.

  2. 첫 println!이 마지막 사용입니다.

    바인딩은 블록 끝까지 남아도, 뒤에서 다시 사용하지 않으면 공유 대여가 새 접근과 충돌하지 않습니다.

  3. 그 뒤 r3 = &mut s가 허용됩니다.

    가변 대여가 활성인 동안에는 r3와 여기서 파생된 재대여를 통해 접근합니다. r3의 마지막 사용 뒤 가변 대여도 끝날 수 있습니다.

  4. 마지막 사용 전의 가변 대여는 거부됩니다.

    뒤에서 공유 참조를 다시 사용한다면 두 대여가 겹치므로 E0502가 발생합니다.

“마지막 사용”은 단순한 소스 줄 순서가 아니라 컴파일러가 제어 흐름에서 판단한 참조의 사용 구간입니다. 이 예에서는 공유 대여가 끝난 뒤에만 같은 String을 가변 대여합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명기존 가로 도표의 6노드와 5간선이다. 참조 생성→공유 대여 활성→활성 대여 없음→가변 대여 활성→종료. 공유 참조를 뒤에서 다시 쓰는 대안 코드에서는 공유 대여 활성에서 E0502 거부로 우회한다. 실패 경로는 성공 경로의 후속 실행이 아니다. 마지막 사용은 여기서 제시한 직선 코드의 조건이며 일반 제어 흐름 설명은 원래 본문에 유지된다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
공유 읽기

r1과 r2가 s를 공유 대여한다.

  • 접근: 공유 읽기
  • r1/r2: 후속 읽기에 필요
  • 현재 활성 공유 접근과 같은 위치의 독립 변경은 겹칠 수 없다.
마지막 사용 뒤

공유 참조를 더 이상 쓰지 않는다.

  • 대여: 공유 접근 불필요
  • 바인딩 블록: 여전히 남음
  • 직선 코드의 마지막 사용과 문법적 스코프를 구분한다.
가변 대여

r3로 같은 s를 변경할 수 있다.

  • 조건: 겹치는 공유 사용 없음
  • 뒤에서 r1/r2를 다시 쓰는 사본은 이 성공 경로가 아닌 컴파일 거부다.

마지막 사용이 공유 대여와 가변 대여를 나눈다

r1/r2 공유 참조의 마지막 사용 뒤 활성 공유 접근이 끝나고 r3 가변 대여가 허용되는 직선 코드의 상태 전환이다. 블록 스코프가 남는 사실과 마지막 사용을 분리하며 뒤에서 공유 참조를 다시 사용하는 E0502는 별도 소스의 컴파일 거부로 표시한다.

같은 String의 공유 참조 r1과 r2가 마지막으로 사용된 뒤 가변 참조 r3를 만들 수 있지만, 공유 참조를 나중에 다시 사용하면 앞선 가변 대여가 거부되는 상태 흐름

BORROW LIVENESS · LAST USE

마지막 공유 참조 사용 뒤에 가변 대여할 수 있다

바인딩의 블록 스코프가 아니라, 제어 흐름에서 참조가 마지막으로 사용되는 지점이 다음 대여와 겹치는지를 봅니다.

공유 대여의 마지막 사용 이후 허용 경로와 조기 변경 거부 경로기존desktop의6노드5간선이다. 참조생성→공유대여활성→활성대여없음→가변대여활성→종료. 공유참조를 뒤에서 다시쓰는 대안 코드에서는 공유대여활성에서 E0502거부로 우회한다. 실패 경로는 성공 경로의 후속실행이 아니다. 마지막사용은 여기서 제시한 직선코드 조건이며 일반제어흐름 설명은 원본문에 유지된다.참조 생성r1, r2: &String공유 대여 활성읽기 · 재공유뒤의 사용까지 유지활성 대여 없음공유 참조 마지막 사용바인딩의 블록은 남음가변 대여 활성r3: &mut String독립 접근은 겹치지 않음가변 대여 종료r3의 마지막 사용 뒤겹치는 대여 거부공유 참조를 뒤에서 사용E0502 · 실행 전 거부완료&mut공유 참조를 재사용거부 경로는 별도 코드 조건허용 뒤에 실패를 실행하지 않음
공유 대여의 마지막 사용과 가변 대여 상태 r1과 r2의 공유 대여가 첫 println에서 마지막으로 사용되면 뒤의 r3 가변 대여가 허용되고, 공유 대여가 이후에도 사용되면 조기 가변 대여는 E0502로 거부됩니다. 마지막 사용&mut s마지막 사용 전 &mut s공유 대여 활성r1, r2: &String읽기·재공유 가능뒤의 사용까지 활성활성 대여 없음after println!공유 참조 마지막 사용바인딩 스코프는 유지가변 대여 활성r3: &mut Stringr3·파생 재대여 경로독립 접근은 겹치지 않음겹치는 접근 · 거부E0502공유 참조를 뒤에서 다시 사용공유 대여와 가변 대여가 겹침r3 마지막 사용 뒤 종료
  1. 공유 참조를 만듭니다.

    r1 = &s와 r2 = &s는 같은 값을 읽는 공유 대여입니다.

  2. 첫 println!이 마지막 사용입니다.

    바인딩은 블록 끝까지 남아도, 뒤에서 다시 사용하지 않으면 공유 대여가 새 접근과 충돌하지 않습니다.

  3. 그 뒤 r3 = &mut s가 허용됩니다.

    가변 대여가 활성인 동안에는 r3와 여기서 파생된 재대여를 통해 접근합니다. r3의 마지막 사용 뒤 가변 대여도 끝날 수 있습니다.

  4. 마지막 사용 전의 가변 대여는 거부됩니다.

    뒤에서 공유 참조를 다시 사용한다면 두 대여가 겹치므로 E0502가 발생합니다.

“마지막 사용”은 단순한 소스 줄 순서가 아니라 컴파일러가 제어 흐름에서 판단한 참조의 사용 구간입니다. 이 예에서는 공유 대여가 끝난 뒤에만 같은 String을 가변 대여합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명기존 가로 도표의 6노드와 5간선이다. 참조 생성→공유 대여 활성→활성 대여 없음→가변 대여 활성→종료. 공유 참조를 뒤에서 다시 쓰는 대안 코드에서는 공유 대여 활성에서 E0502 거부로 우회한다. 실패 경로는 성공 경로의 후속 실행이 아니다. 마지막 사용은 여기서 제시한 직선 코드의 조건이며 일반 제어 흐름 설명은 원래 본문에 유지된다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
공유 읽기

r1과 r2가 s를 공유 대여한다.

  • 접근: 공유 읽기
  • r1/r2: 후속 읽기에 필요
  • 현재 활성 공유 접근과 같은 위치의 독립 변경은 겹칠 수 없다.
마지막 사용 뒤

공유 참조를 더 이상 쓰지 않는다.

  • 대여: 공유 접근 불필요
  • 바인딩 블록: 여전히 남음
  • 직선 코드의 마지막 사용과 문법적 스코프를 구분한다.
가변 대여

r3로 같은 s를 변경할 수 있다.

  • 조건: 겹치는 공유 사용 없음
  • 뒤에서 r1/r2를 다시 쓰는 사본은 이 성공 경로가 아닌 컴파일 거부다.

r1과 r2가 s를 함께 빌리는 동안에는 읽기만 할 수 있습니다. 두 참조를 더 이상 쓰지 않으면 활성인 대여가 없어지고, 그때 r3로 같은 s를 가변으로 빌려 고칠 수 있습니다. 반대로 r3를 만든 뒤에 r1을 다시 쓰는 코드라면, 공유 대여가 그때까지 이어지므로 E0502 오류로 거부됩니다.

이런 대여 오류는 답답할 수 있지만, 러스트 컴파일러가 숨어 있는 오류를 실행 전에 미리 찾아 정확히 어디가 문제인지 알려 주는 것입니다. 데이터가 왜 예상과 다른지 실행 중에 하나하나 추적할 필요가 없어집니다.

더 깊이: 대여 검사기가 마지막 사용을 보게 된 과정

참조가 유지되는 구간을 블록 끝이 아니라 마지막 사용까지로 보는 지금의 대여 검사를 NLL(non-lexical lifetimes)이라고 부릅니다. 이름 그대로, 참조의 구간을 변수가 선언된 블록이라는 어휘적 범위(lexical scope)에 묶지 않는다는 뜻입니다. 러스트 블로그에 따르면 NLL은 Rust 2018 에디션과 함께 그 에디션의 코드에만 먼저 켜졌고, 2015 에디션의 코드는 옛 검사기와 새 검사기를 함께 돌려 결과를 비교하는 이행 모드로 검사되었습니다. Rust 1.63부터는 모든 코드가 NLL로만 검사됩니다.

마지막 사용만으로는 설명되지 않는 경우도 있습니다. v.push(v.len())은 push를 부르려고 v를 가변으로 빌린 채 인수에서 v.len()으로 v를 다시 읽는 것처럼 보이지만 컴파일됩니다. 러스트 컴파일러 개발 안내서는 이것을 2단계 대여(two-phase borrow)로 설명합니다. 메서드를 부를 때 자동으로 만드는 가변 대여 같은 몇몇 암묵적인 가변 대여는, 먼저 예약(reservation)되어 공유 대여처럼 동작하다가 실제로 함수가 불리는 시점에 활성화(activation)되어 가변 대여가 됩니다. 직접 쓴 &mut에는 이 규칙이 적용되지 않으므로, let r = &mut v; 다음에 v.len()을 읽고 다시 r을 쓰는 코드는 여전히 거부됩니다.

댕글링 참조

포인터가 있는 언어에서는 댕글링 포인터(dangling pointer)를 만들기 쉽습니다. 메모리를 해제했는데 그 메모리를 가리키는 포인터가 남아 있어, 그 메모리가 다른 곳에 쓰인 뒤에도 그 포인터로 접근할 수 있는 상태입니다. 러스트에서는 컴파일러가 참조가 댕글링 참조가 되지 않도록 보장합니다. 어떤 데이터의 참조가 있으면, 그 참조를 쓰는 동안 데이터가 먼저 스코프를 벗어나지 않는지 컴파일러가 확인합니다. 댕글링 참조를 만들어 보면 컴파일러가 어떻게 막는지 알 수 있습니다.

src/main.rs (컴파일되지 않음)
fn main() {
    let reference_to_nothing = dangle();
}

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

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

이 메시지에는 아직 다루지 않은 라이프타임(lifetime)이라는 기능이 나오며, 라이프타임은 라이프타임과 참조 유효성에서 자세히 다룹니다. 라이프타임 부분을 빼고 보면, 이 함수의 반환 타입은 빌린 값을 담고 있는데 빌려 올 값이 없다는 도움말이 핵심입니다. 그 뒤에는 참조 대신 소유한 값을 돌려주라는 도움말도 이어집니다. dangle 안에서 무슨 일이 일어나는지 차례로 보면 다음과 같습니다.

fn dangle() -> &String { // dangle은 String의 참조를 돌려줌

    let s = String::from("hello"); // s는 새 String

    &s // String s의 참조를 돌려줌
} // 여기서 s가 스코프를 벗어나 버려지고, 그 메모리가 해제됨

s는 dangle 안에서 만들어졌으므로 dangle이 끝나면 해제됩니다. 그런데 &s를 돌려주려 했으니, 해제된 String을 가리키는 참조를 돌려주는 셈입니다. 러스트는 이것을 허용하지 않습니다. 이런 경우에는 참조 대신 String을 그대로 돌려주면 됩니다.

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

    s
}

이 코드는 아무 문제 없이 동작합니다. 소유권이 함수 밖으로 이동하므로, 함수가 끝날 때 아무것도 해제되지 않습니다.

참조자 규칙

참조에 대해 배운 내용을 정리하면 다음 두 규칙입니다.

  • 어느 시점이든, 한 값에 대해 가변 참조 하나만 있거나 불변 참조 여러 개만 있을 수 있습니다.
  • 참조는 언제나 유효해야 합니다.

아래 표는 같은 값을 빌리는 여러 경우를 정리한 것입니다. 기준은 참조 변수의 개수가 아니라, 같은 값을 빌리는 구간이 실제로 쓰이는 동안 겹치는지와, 그 구간 내내 빌린 값이 유효한지입니다.

같은 위치의 공유 대여, 가변 대여, 충돌하는 대여, 지역 값을 가리키는 반환 참조를 접근 권한과 소유권 및 컴파일 결과로 비교

SAME PLACE · OVERLAPPING ACCESS

대여는 같은 위치의 접근 구간과 유효성을 검사한다

참조 변수의 총개수가 아니라 같은 위치에 대한 대여가 실제 사용 구간에서 겹치는지, 대상이 그 구간 내내 유효한지를 확인합니다.

안전한 러스트에서 같은 위치를 대여할 때의 접근·소유권 계약
대여 상황 활성 구간의 접근 계약 소유권과 컴파일 결과

여러 공유 대여

r1 = &s

r2 = &s

참조를 통해 읽거나 다시 공유 대여할 수 있습니다. 일반 데이터는 공유 대여가 활성인 동안 변경하지 않습니다. s가 계속 소유합니다. 참조의 사용이 끝나도 대상 값을 drop하지 않으며, 조합은 허용됩니다.

하나의 가변 대여

r = &mut s

r와 여기서 파생된 재대여를 통해 읽고 쓸 수 있습니다. 같은 위치의 독립 접근은 겹칠 수 없습니다. s의 소유권은 이동하지 않습니다. 충돌하는 활성 대여가 없다면 조합은 허용됩니다.

충돌하는 활성 대여

&s + &mut s

두 &mut s

읽기 안정성과 쓰기 독점을 동시에 만족할 수 없습니다. 나중 사용 때문에 앞선 대여가 계속 활성일 수도 있습니다. E0502 또는 E0499 등으로 거부됩니다. 대여의 마지막 사용을 분리해야 합니다.

대상보다 오래 쓰려는 참조

fn dangle()

-> &String

함수 안의 지역 String은 반환 뒤 유효하지 않으므로 그 참조를 호출자에서 사용할 수 없습니다. 유효한 빌림 원본이 없어 거부됩니다. 이 예에서는 String 자체를 반환해 소유권을 이동합니다.
여러 &T · 허용
접근 읽기와 재공유가 가능하고 일반 데이터의 변경은 허용되지 않습니다.
소유권 원래 소유자에게 남으며 참조가 끝나도 대상은 drop되지 않습니다.
하나의 &mut T · 허용
접근 가변 참조와 파생 재대여를 통해 읽고 씁니다.
조건 같은 위치의 독립된 공유·가변 대여가 활성 상태로 겹치지 않아야 합니다.
겹치는 공유·가변 또는 두 가변 · 거부
원인 뒤의 사용 때문에 앞선 대여가 아직 활성일 수 있습니다.
진단 대표적으로 E0502와 E0499가 발생합니다.
대상보다 오래 쓰려는 참조 · 거부
원인 지역 값은 함수 반환 뒤 유효하지 않습니다.
해결 이 예에서는 String 자체를 반환해 소유권을 이동합니다.
이 표는 안전한 러스트에서 같은 위치를 대여하는 입문 규칙입니다. 서로 겹치지 않는 구조체 필드는 별도로 분석될 수 있고, UnsafeCell 기반 내부 가변성 타입은 안전한 API에서 별도의 검사 계약을 제공합니다.

불변 참조가 여럿이면 모두 읽기만 하며 원래 변수가 계속 소유합니다. 가변 참조가 하나면 그 참조로 읽고 쓸 수 있지만 그동안 같은 값에 다른 접근은 할 수 없고, 소유권은 여전히 원래 변수에 있습니다. 쓰이는 구간이 겹치는 가변 대여와 다른 대여는 E0499나 E0502로 거부되며, 지역 값을 가리키는 참조를 돌려주려 하면 E0106으로 거부됩니다. 더 자세한 규칙은 공식 Book의 References and Borrowing과 Reference의 Borrow operators에 있습니다.

정리

  • 참조는 값을 소유하지 않고 가리키며, &로 만들고 참조를 만드는 것을 대여라고 합니다.
  • 참조는 기본으로 불변이어서 가리키는 값을 고칠 수 없고, 고치려면 &mut으로 가변 참조를 만듭니다.
  • 한 값에 대해서는 가변 참조 하나나 불변 참조 여러 개만 동시에 있을 수 있으며, 이 규칙이 데이터 경쟁을 막습니다.
  • 참조가 유지되는 구간은 그 참조를 마지막으로 쓰는 곳까지이므로, 마지막 사용 뒤에는 다른 대여를 할 수 있습니다.
  • 러스트는 참조가 가리키는 값보다 오래 쓰이는 댕글링 참조를 컴파일할 때 거부하며, 함수 안에서 만든 값은 참조 대신 그대로 돌려줍니다.

연습 문제

1. 마지막 사용 뒤에 고치기

다음 프로그램은 무엇을 출력하나요? 맨 끝에 println!("again={read}");를 더하면 어떻게 되나요?

src/main.rs
fn main() {
    let mut s = String::from("spring");
    let read = &s;
    println!("before={read}");

    let edit = &mut s;
    edit.push_str("time");
    println!("after={edit}");

    println!("owner={s}");
}
해설 보기

before=spring, after=springtime, owner=springtime을 출력합니다. read는 첫 println!에서 마지막으로 쓰이므로 그 뒤의 &mut s와 겹치지 않고, edit도 두 번째 println! 뒤로 쓰이지 않으므로 마지막 줄에서 s를 다시 읽을 수 있습니다. 끝에 read를 다시 쓰는 줄을 더하면 read의 공유 대여가 그 줄까지 이어져 그 사이의 &mut s와 겹치므로, E0502 오류로 컴파일되지 않고 세 줄도 출력되지 않습니다.

2. 고칠 수 있는 참조인가

다음 함수 가운데 컴파일되는 것은 무엇인가요? (가) fn add_one(v: &Vec<i32>) { v.push(1); } (나) fn add_one(v: &mut Vec<i32>) { v.push(1); } (다) fn first_len(v: &Vec<String>) -> usize { v[0].len() }

해설 보기

(나)와 (다)가 컴파일됩니다. (가)는 불변 참조로 받은 벡터에 원소를 더하려 하므로 E0596 오류가 나며, 매개변수를 &mut Vec<i32>로 바꿔야 합니다. (다)는 벡터의 첫 원소를 읽어 길이를 구할 뿐이므로 불변 참조로 충분합니다. 다만 빈 벡터를 넘기면 v[0]이 범위를 벗어나 실행 중에 패닉이 납니다.

3. 참조를 돌려줄 수 있을 때

fn longest_name() -> &String은 E0106 오류로 컴파일되지 않았습니다. 같은 일을 하는 함수를 컴파일되게 만드는 방법 두 가지를 들어 보세요.

해설 보기

첫째, 함수 안에서 만든 값을 참조 대신 그대로 돌려주어 fn longest_name() -> String으로 바꾸면 소유권이 부른 쪽으로 이동합니다. 둘째, 함수 밖에 이미 있는 값을 매개변수의 참조로 받아, 그 안의 값을 가리키는 참조를 돌려줍니다. 예를 들어 fn first(names: &Vec<String>) -> &String { &names[0] }은 돌려주는 참조가 names가 가리키는 벡터에서 빌린 것이므로 컴파일되며, 매개변수가 하나라 라이프타임을 따로 적지 않아도 됩니다.

다음 글 슬라이스에서는 컬렉션 전체가 아니라 그 안의 연속된 일부를 가리키는 또 다른 참조인 슬라이스를 살펴봅니다.