Devin.KR

빌림과 참조 - 빌림 검사기와 친해지기

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서 Rust 의 모든 값에는 주인이 하나뿐이고, 값을 함수에 넘기면 주인이 바뀐다는 것을 배웠다. 그런데 함수에 값을 넘길 때마다 주인이 바뀌면 불편하다. 가계부의 지출 목록을 합계 함수에 넘겼다고 해서 그 목록을 더 쓰지 못하게 된다면 곤란하다. 이 장에서는 주인을 바꾸지 않고 값을 잠깐 빌려 쓰는 참조(reference)를 다룬다. 참조에는 컴파일러가 검사하는 규칙이 있다. 이 규칙을 만든 이유와, 규칙에 걸렸을 때 읽어야 할 오류 메시지의 요지를 함께 살핀다.

  • & 로 값을 읽기 전용으로 빌리고, &mut 로 값을 바꾸기 위해 빌리는 방법을 안다.
  • 불변 참조는 동시에 여러 개 가능하고 가변 참조는 한 번에 하나만 가능한 이유를 설명한다.
  • 컴파일러가 댕글링 참조(dangling reference, 이미 사라진 값을 가리키는 참조)를 어떻게 막는지 오류 메시지로 확인한다.
  • 문자열 슬라이스 &str 과 배열 슬라이스 &[T] 로 데이터 일부를 복사 없이 다룬다.

문제 상황

가계부 도구에 지출 내역이 다섯 건 들어 있다고 하자. 합계를 구하는 함수, 금액을 100원 단위로 내림하는 함수, "식비:점심 국밥" 같은 메모 한 줄에서 분류만 잘라 내는 함수가 필요하다. 앞 장의 방식대로 짜면 함수마다 값을 통째로 넘기고 결과와 함께 돌려받아야 한다. 합계를 구하려고 목록의 주인을 넘겼다가 다시 돌려받는 코드는 읽기도 어렵고 실수하기도 쉽다.

또 다른 상황도 있다. 합계를 계산하는 중에 다른 곳에서 같은 목록의 금액을 고치면 합계가 어느 시점의 값인지 알 수 없게 된다. 다른 언어에서는 이런 문제가 실행 중에 드러나거나 아예 드러나지 않는다. Rust 는 읽는 쪽과 쓰는 쪽이 겹치지 않도록 컴파일 단계에서 규칙을 강제한다. 이 장의 규칙은 그 강제가 어떤 모양인지를 정리한 것이다.

참조와 빌림 규칙

& 로 읽기 전용으로 빌리기

값 앞에 & 를 붙이면 그 값을 가리키는 참조가 만들어진다. 참조는 값의 주인이 아니므로 참조가 사라져도 값은 지워지지 않는다. 이렇게 값을 잠깐 쓰는 일을 빌림(borrowing)이라고 부른다. 함수 매개변수의 타입을 &i64 나 &[i64] 처럼 쓰면 그 함수는 값을 빌려서 읽기만 한다.

fn total(amounts: &[i64]) -> i64 {
    let mut sum = 0;
    for a in amounts {
        sum += *a;
    }
    sum
}

참조가 가리키는 값을 꺼내려면 앞에 * 를 붙인다. 이것을 역참조(dereference)라고 한다. 위 반복문에서 a 의 타입은 &i64 이고 *a 가 i64 값이다. 메서드를 호출하거나 출력할 때는 Rust 가 알아서 역참조해 주는 경우가 많지만, 산술과 대입에서는 * 를 직접 쓰는 편이 무슨 일이 일어나는지 분명하다.

&mut 로 바꾸기 위해 빌리기

값을 고치려면 변수를 mut 로 선언하고 &mut 로 빌려야 한다. 빌려 준 쪽 변수가 mut 가 아니면 &mut 를 만들 수 없다.

fn add_income(balance: &mut i64, amount: i64) {
    *balance += amount;
}

호출할 때도 add_income(&mut balance, 250_000) 처럼 &mut 를 적는다. 함수 정의와 호출 양쪽에 표시가 있으므로 코드만 읽어도 어느 함수가 값을 바꾸는지 알 수 있다.

두 가지 규칙

빌림 검사기(borrow checker)가 강제하는 규칙은 사실상 두 문장이다. 같은 값에 대해 어느 한 시점에는 (1) 불변 참조가 몇 개든 있거나, (2) 가변 참조가 정확히 하나만 있어야 한다. 둘을 섞을 수는 없다. 여기서 "어느 한 시점"은 중괄호가 끝나는 곳이 아니라 참조를 마지막으로 쓰는 곳까지를 뜻한다. 마지막 사용 이후에는 참조가 살아 있다고 보지 않으며, 이 동작을 NLL(non-lexical lifetimes)이라고 부른다.

값을 바꾸는 쪽이 없으면 여럿이 동시에 읽어도 되지만, 쓰는 쪽이 있으면 다른 접근은 함께 있을 수 없다.
참조 종류별 규칙
종류만드는 법동시에 몇 개할 수 있는 일
불변 참조&값여러 개읽기
가변 참조&mut 값하나읽기와 쓰기
둘의 혼합-불가컴파일 오류

읽기만 하는 참조가 여럿인 것은 문제가 없다. 아무도 값을 바꾸지 않으니 어디서 읽어도 같은 값이다. 문제는 읽는 도중에 값이 바뀌는 경우다. 다음 코드는 이 규칙을 어긴다.

fn main() {
    let mut balance: i64 = 100_000;
    let r = &balance;
    balance += 5_000;
    println!("{}", r);
}

컴파일하면 E0506 오류가 나며 요지는 "balance 는 빌려 준 상태이므로 대입할 수 없다"이다. r 을 만든 뒤 println 에서 r 을 다시 쓰기 때문에 r 이 살아 있는 구간에 대입이 끼어든 것이다. println 줄을 지우면 r 이 더 쓰이지 않으므로 같은 코드가 컴파일된다.

가변 참조를 둘 만드는 경우도 마찬가지로 막힌다. 이때는 E0499 이며 요지는 "한 번에 둘 이상의 가변 참조로 빌릴 수 없다"이다. 이 규칙 덕분에 두 곳이 같은 값을 동시에 고쳐서 결과가 뒤섞이는 일이 원천적으로 사라진다.

댕글링 참조 방지

참조는 가리키는 값보다 오래 살 수 없다. 값이 먼저 사라졌는데 참조가 남아 있으면 그 참조는 이미 해제된 메모리를 가리키게 된다. 이것이 댕글링 참조다. C 나 C++ 에서는 이런 코드가 컴파일되고 실행 중에 이상한 값을 읽는다. Rust 는 컴파일 단계에서 거절한다.

fn main() {
    let r;
    {
        let x = 5_000;
        r = &x;
    }
    println!("{}", r);
}

오류는 E0597 이며 요지는 "x 가 충분히 오래 살지 못한다. 빌린 값이 안쪽 중괄호가 끝나는 곳에서 버려지는데 r 은 그 뒤에도 쓰인다"이다. 함수에서도 같은 일이 생긴다. 함수 안에서 만든 String 의 참조를 돌려주려 하면 함수가 끝날 때 String 이 사라지므로 컴파일러가 거절한다. 반환 타입에 & 만 쓴 경우에는 E0106 (수명 표시자가 필요하다는 오류)이 나오는데, 수명(lifetime)은 이후 장에서 자세히 다룬다. 지금은 해결 방법만 알면 된다. 함수 안에서 만든 값은 참조가 아니라 값 자체를 돌려주어 주인을 호출한 쪽으로 넘긴다.

슬라이스: 데이터의 일부를 빌리기

슬라이스(slice)는 연속된 데이터의 일부 구간을 가리키는 참조다. 복사하지 않고 원본의 시작 위치와 길이만 들고 있다. 배열이나 Vec 의 구간은 &[T] 로, 문자열의 구간은 &str 로 쓴다. 구간은 [시작..끝] 으로 지정하며 끝은 포함하지 않는다. 시작을 생략하면 처음부터, 끝을 생략하면 마지막까지다.

슬라이스는 원본의 시작 위치와 길이만 가지며 데이터를 복사하지 않는다.

슬라이스도 참조이므로 빌림 규칙이 그대로 적용된다. 배열의 일부를 슬라이스로 빌려 둔 동안에는 그 배열을 고칠 수 없다. 이 성질 덕분에 "슬라이스가 가리키는 구간이 갑자기 바뀌거나 사라지는" 일이 없다.

함수 매개변수는 슬라이스로 받는다

합계 함수의 매개변수를 &[i64] 로 받으면 배열 전체, 배열의 일부 구간, 나중에 배울 Vec 을 모두 같은 함수에 넘길 수 있다. 문자열도 같다. 매개변수를 &String 이 아니라 &str 로 받으면 문자열 리터럴과 String 의 참조를 모두 받는다. 읽기만 하는 함수라면 슬라이스 타입을 쓰는 것이 기본이다.

문자열 슬라이스는 바이트 위치로 자른다

Rust 의 문자열은 UTF-8 로 저장된다. 영문은 한 글자가 1바이트지만 한글은 한 글자가 3바이트다. &s[0..3] 처럼 범위에 쓰는 숫자는 글자 수가 아니라 바이트 위치이며, 글자의 중간을 자르면 프로그램이 멈춘다(panic). 그래서 아래 예제는 글자 위치를 알려 주는 char_indices 로 구분자 ':' 의 바이트 위치를 찾아서 자른다. 구분자 ':' 는 1바이트이므로 그 다음 글자의 시작 위치는 위치 + 1 이다.

슬라이스 타입과 만드는 법
타입가리키는 것만드는 예결과 예
&[i64]배열의 구간&expenses[1..4][1400, 30000, 8900]
&[i64]배열 전체&expenses[..]다섯 건 전부
&str문자열의 구간&line[..6]"식비"
&str문자열 리터럴"교통""교통"

완성 코드

지금까지의 내용을 모은 프로그램이다. src/main.rs 하나로 동작하며 외부 크레이트가 필요 없다. 지출 다섯 건의 합계를 구하고, 100원 단위로 내림하고, 잔액을 갱신한 뒤, 메모 문자열에서 분류와 내용을 잘라 낸다.

fn total(amounts: &[i64]) -> i64 {
    let mut sum = 0;
    for a in amounts {
        sum += *a;
    }
    sum
}

fn round_down_to_100(amounts: &mut [i64]) {
    for x in amounts {
        *x = *x / 100 * 100;
    }
}

fn add_income(balance: &mut i64, amount: i64) {
    *balance += amount;
}

fn colon_pos(line: &str) -> usize {
    for (i, c) in line.char_indices() {
        if c == ':' {
            return i;
        }
    }
    line.len()
}

fn category_of(line: &str) -> &str {
    &line[..colon_pos(line)]
}

fn detail_of(line: &str) -> &str {
    let pos = colon_pos(line);
    if pos == line.len() {
        ""
    } else {
        &line[pos + 1..]
    }
}

fn main() {
    let mut balance: i64 = 100_000;
    let mut expenses: [i64; 5] = [12_000, 1_450, 30_000, 8_900, 250];

    let all = &expenses;
    let head = &expenses[..2];
    println!("전체 합계: {}", total(all));
    println!("앞 두 건 합계: {}", total(head));

    round_down_to_100(&mut expenses);
    println!("100원 단위 내림 후: {:?}", expenses);
    println!("가운데 세 건: {:?}", &expenses[1..4]);

    add_income(&mut balance, 250_000);
    balance -= total(&expenses);
    println!("잔액: {}", balance);

    let memos = ["식비:점심 국밥", "교통:버스", "메모없음"];
    for memo in memos {
        println!(
            "{} -> 분류={}, 내용={}",
            memo,
            category_of(memo),
            detail_of(memo)
        );
    }
}

줄별 해설

  • total: 매개변수 &[i64] 는 i64 배열의 구간을 빌린다는 뜻이다. 반복문의 a 는 &i64 이므로 *a 로 값을 꺼내 더한다. sum 의 타입은 반환 타입에서 i64 로 정해진다.
  • round_down_to_100: &mut [i64] 를 받아 각 칸을 직접 고친다. 가변 참조를 반복하면 각 원소의 &mut i64 가 나오며, *x 에 대입해야 원본이 바뀐다. 정수 나눗셈은 소수점 아래를 버리므로 나누고 곱하면 100원 단위 내림이 된다.
  • add_income: 잔액을 &mut i64 로 받아 *balance 에 더한다. 함수는 값을 돌려주지 않고 호출한 쪽의 변수를 바로 바꾼다.
  • colon_pos: char_indices 는 (바이트 위치, 글자) 쌍을 차례로 내놓는다. ':' 를 만나면 그 바이트 위치를 돌려주고, 끝까지 없으면 문자열 길이를 돌려준다. 없음을 나타내는 더 알맞은 방법은 Option 을 다루는 장에서 배운다.
  • category_of: 반환 타입 &str 은 매개변수 line 의 일부를 빌린 것이다. 매개변수 참조가 하나뿐이면 컴파일러가 반환 참조도 그것에서 나왔다고 보므로 따로 표시하지 않아도 된다. 이 규칙의 원리는 수명을 다루는 장에서 설명한다.
  • detail_of: 구분자가 없으면 빈 문자열 리터럴을, 있으면 구분자 다음부터 끝까지의 슬라이스를 돌려준다. 두 분기의 타입이 모두 &str 이라 if 식으로 쓸 수 있다.
  • main 의 all 과 head: expenses 를 두 번 불변으로 빌린다. 불변 참조는 여러 개 가능하므로 규칙에 맞다. total(all) 에서 &[i64; 5] 는 &[i64] 로 자동 변환된다.
  • round_down_to_100(&mut expenses): 이 시점에 all 과 head 는 이미 마지막 사용을 마쳤다. 그래서 가변 참조를 만들 수 있다. 앞의 두 println 줄과 이 줄의 순서를 바꾸면 컴파일 오류가 난다.
  • balance -= total(&expenses): 내림한 지출 합계를 잔액에서 뺀다. total 이 빌린 참조는 함수가 끝나면 사라지므로 이 뒤에도 expenses 를 쓸 수 있다.
  • for memo in memos: 배열을 값으로 순회하며 memo 는 &str 이다. 리터럴은 프로그램 전체 기간 동안 살아 있으므로 어디에 빌려 넘겨도 문제가 없다.

실행 결과

$ cargo run
전체 합계: 52600
앞 두 건 합계: 13450
100원 단위 내림 후: [12000, 1400, 30000, 8900, 200]
가운데 세 건: [1400, 30000, 8900]
잔액: 297500
식비:점심 국밥 -> 분류=식비, 내용=점심 국밥
교통:버스 -> 분류=교통, 내용=버스
메모없음 -> 분류=메모없음, 내용=

잔액은 100,000 에 250,000 을 더한 350,000 에서 내림한 지출 합계 52,500 을 뺀 297,500 이다. cargo 가 컴파일 과정을 먼저 출력하지만 경고는 없어야 한다.

실무에서 자주 틀리는 것

읽는 참조가 살아 있는 동안 원본을 고친다

틀린 코드는 빌린 값을 나중에 다시 읽으면서 그 사이에 값을 바꾼다.

let mut balance: i64 = 100_000;
let before = &balance;
balance -= 3_000;
println!("변경 전 잔액: {}", before);

E0506 오류가 나며 요지는 "balance 가 빌려진 상태라 대입할 수 없다"이다. 변경 전 값이 필요하면 참조 대신 값을 복사해 둔다. i64 는 복사가 싸고 앞 장에서 배운 대로 복사되는 타입이다.

let mut balance: i64 = 100_000;
let before = balance;
balance -= 3_000;
println!("변경 전 잔액: {}", before);

가변 참조를 둘 만든다

let mut balance: i64 = 100_000;
let a = &mut balance;
let b = &mut balance;
*a += 1;
*b += 1;

E0499 오류이며 요지는 "balance 를 한 번에 둘 이상 가변으로 빌릴 수 없다"이다. 한 참조의 사용을 끝낸 뒤에 다음 참조를 만들면 된다.

let mut balance: i64 = 100_000;
let a = &mut balance;
*a += 1;
let b = &mut balance;
*b += 1;

함수 안에서 만든 값의 참조를 돌려준다

fn make_label() -> &String {
    let s = String::from("식비");
    &s
}

E0106 오류로 요지는 "반환 타입에 빌린 값이 있는데 빌려 올 곳이 없다"이다. 함수 안의 s 는 함수가 끝나면 사라지기 때문이다. 값 자체를 돌려주면 주인이 호출한 쪽으로 넘어간다.

fn make_label() -> String {
    String::from("식비")
}

한글 문자열을 글자 수로 자른다

let line = "식비:점심";
let cat = &line[0..1];

이 코드는 컴파일은 되지만 실행하면 멈춘다. 요지는 "바이트 위치 1 은 글자 경계가 아니라 '식'(0..3 바이트)의 중간이다"라는 메시지다. 한글 한 글자가 3바이트이기 때문이다. 경계를 직접 세지 말고 예제의 colon_pos 처럼 char_indices 로 구한 위치를 쓴다. 구분자 앞부분이 '식비' 라면 위치는 6 이다.

let line = "식비:점심";
let cat = &line[..6];

한눈에 보기

빌림과 슬라이스 요약
주제규칙어기면대처
불변 참조 &동시에 여러 개, 읽기만쓰려 하면 오류&mut 로 바꾸기
가변 참조 &mut한 번에 하나E0499, E0502사용 구간을 나누기
참조와 원본참조가 살아 있는 동안 원본 수정 불가E0506값을 복사해 두기
댕글링 방지참조는 값보다 오래 못 산다E0597, E0106값을 돌려주기
슬라이스구간을 복사 없이 빌림글자 경계 밖이면 panicchar_indices 사용

연습 문제

  1. 다음 코드가 컴파일되지 않는 이유를 오류 번호와 함께 설명하고, 순서만 바꿔 컴파일되게 고쳐라.
    let mut memo = String::from("식비");
    let view = &memo;
    memo.push_str(":점심");
    println!("{}", view);
  2. 지출 슬라이스에서 가장 큰 금액을 돌려주는 함수 largest(amounts: &[i64]) -> i64 를 작성하라. 슬라이스는 비어 있지 않다고 가정한다. 배열 전체와 배열의 뒤 세 건에 각각 호출하는 코드도 써라.
  3. 지출 슬라이스의 모든 금액에 수수료 10원을 더하는 함수를 작성하라. 매개변수 타입은 무엇이어야 하며, 호출하는 쪽 변수에는 어떤 선언이 필요한가.
  4. 다음 코드에서 컴파일러가 거절하는 이유를 설명하고 고쳐라.
    let r;
    {
        let fee = 500;
        r = &fee;
    }
    println!("수수료 {}", r);

정답과 해설

  1. view 가 println 에서 다시 쓰이므로, push_str 이 가변으로 빌리는 시점에 불변 참조가 살아 있다. 오류는 E0502 다. 출력을 먼저 하고 수정하면 된다.
    let mut memo = String::from("식비");
    let view = &memo;
    println!("{}", view);
    memo.push_str(":점심");
    view 의 마지막 사용이 push_str 앞에 오므로 두 빌림이 겹치지 않는다.
  2. 첫 원소를 기준값으로 잡고 나머지와 비교한다.
    fn largest(amounts: &[i64]) -> i64 {
        let mut best = amounts[0];
        for a in amounts {
            if *a > best {
                best = *a;
            }
        }
        best
    }
    
    fn main() {
        let expenses: [i64; 5] = [12_000, 1_450, 30_000, 8_900, 250];
        println!("{}", largest(&expenses));
        println!("{}", largest(&expenses[2..]));
    }
    두 호출 모두 30000 을 출력한다. 같은 함수가 배열 전체와 구간을 모두 받는 것이 슬라이스 매개변수의 장점이다.
  3. 값을 바꿔야 하므로 매개변수는 &mut [i64] 여야 하고, 호출하는 쪽 변수는 let mut 으로 선언하며 &mut 로 넘긴다.
    fn add_fee(amounts: &mut [i64]) {
        for x in amounts {
            *x += 10;
        }
    }
    
    fn main() {
        let mut expenses: [i64; 3] = [1_000, 2_000, 3_000];
        add_fee(&mut expenses);
        println!("{:?}", expenses);
    }
    출력은 [1010, 2010, 3010] 이다.
  4. fee 는 안쪽 중괄호가 끝날 때 사라지는데 r 은 그 뒤에도 쓰이므로 E0597 (빌린 값이 충분히 오래 살지 못함)이 난다. 값이 바깥에서 살아 있도록 선언 위치를 옮기면 된다.
    let fee = 500;
    let r = &fee;
    println!("수수료 {}", r);
    참조는 언제나 가리키는 값보다 짧게 살아야 한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.