Rust · 기본
소유권부터 배우는 Rust
빌림과 참조 - 빌림 검사기와 친해지기
& 와 &mut 규칙, 동시에 여러 불변 참조, 댕글링 참조 방지, 슬라이스 &str·&[T]
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 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 | 값을 돌려주기 |
| 슬라이스 | 구간을 복사 없이 빌림 | 글자 경계 밖이면 panic | char_indices 사용 |
연습 문제
- 다음 코드가 컴파일되지 않는 이유를 오류 번호와 함께 설명하고, 순서만 바꿔 컴파일되게 고쳐라.
let mut memo = String::from("식비"); let view = &memo; memo.push_str(":점심"); println!("{}", view); - 지출 슬라이스에서 가장 큰 금액을 돌려주는 함수
largest(amounts: &[i64]) -> i64를 작성하라. 슬라이스는 비어 있지 않다고 가정한다. 배열 전체와 배열의 뒤 세 건에 각각 호출하는 코드도 써라. - 지출 슬라이스의 모든 금액에 수수료 10원을 더하는 함수를 작성하라. 매개변수 타입은 무엇이어야 하며, 호출하는 쪽 변수에는 어떤 선언이 필요한가.
- 다음 코드에서 컴파일러가 거절하는 이유를 설명하고 고쳐라.
let r; { let fee = 500; r = &fee; } println!("수수료 {}", r);
정답과 해설
- view 가 println 에서 다시 쓰이므로, push_str 이 가변으로 빌리는 시점에 불변 참조가 살아 있다. 오류는 E0502 다. 출력을 먼저 하고 수정하면 된다.
view 의 마지막 사용이 push_str 앞에 오므로 두 빌림이 겹치지 않는다.let mut memo = String::from("식비"); let view = &memo; println!("{}", view); memo.push_str(":점심"); - 첫 원소를 기준값으로 잡고 나머지와 비교한다.
두 호출 모두 30000 을 출력한다. 같은 함수가 배열 전체와 구간을 모두 받는 것이 슬라이스 매개변수의 장점이다.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..])); } - 값을 바꿔야 하므로 매개변수는
&mut [i64]여야 하고, 호출하는 쪽 변수는let mut으로 선언하며&mut로 넘긴다.
출력은 [1010, 2010, 3010] 이다.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); } - fee 는 안쪽 중괄호가 끝날 때 사라지는데 r 은 그 뒤에도 쓰이므로 E0597 (빌린 값이 충분히 오래 살지 못함)이 난다. 값이 바깥에서 살아 있도록 선언 위치를 옮기면 된다.
참조는 언제나 가리키는 값보다 짧게 살아야 한다.let fee = 500; let r = &fee; println!("수수료 {}", r);
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.