Rust · 기본
소유권부터 배우는 Rust
수명 - 참조가 얼마나 살아 있는가
수명 주석이 필요한 경우, 수명 생략 규칙, 참조를 담는 구조체, 'static
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 트레이트와 제네릭으로 서로 다른 타입에 공통 동작을 약속하는 방법을 다뤘다. 이 장은 그 제네릭 문법을 참조에 적용한다. 앞서 본 함수와 구조체는 값을 소유했거나 참조를 잠깐 빌려 쓰고 돌려주기만 했다. 참조를 함수에서 돌려주거나 구조체 안에 오래 보관하면, 컴파일러는 그 참조가 가리키는 값이 얼마나 오래 살아 있는지 알아야 한다. 그 기간을 수명(lifetime)이라 부르고, 이름표를 붙여 컴파일러에게 알려 주는 문법이 수명 주석이다.
가계부 도구에서 지출 내역을 담은 목록을 빌려 요약 정보를 만드는 작은 프로그램을 쓰며 이 문법을 익힌다.
- 참조를 돌려주는 함수에 수명 주석이 필요한 이유와 쓰는 법을 설명할 수 있다.
- 수명 생략 규칙 세 가지를 적용해, 주석을 써야 하는 함수와 생략해도 되는 함수를 구분한다.
- 참조를 필드로 갖는 구조체와 그
impl블록을 작성한다. 'static이 무엇을 뜻하고 무엇을 뜻하지 않는지 말할 수 있다.- 수명 관련 컴파일 오류 메시지를 읽고 원인을 찾는다.
문제 상황
가계부에 지출 기록이 수백 건 쌓여 있다고 하자. 월말 요약을 만들 때마다 기록을 복제하면 메모리와 시간이 낭비되므로, 기록 목록은 그대로 두고 “이 목록을 빌려 보는 요약 객체”를 만들고 싶다. 요약 객체에는 제목과 기록 목록에 대한 참조가 들어간다. 여기서 두 가지 질문이 생긴다.
- 두 문자열 참조 중 하나를 골라 돌려주는 함수는, 돌려준 참조가 어느 쪽 값에 묶여 있다고 표시해야 하는가.
- 참조를 필드로 가진 구조체는, 참조 대상보다 오래 살아남지 않는다는 것을 어떻게 보장하는가.
C 계열 언어에서는 이런 코드가 컴파일되고, 대상이 사라진 뒤 실행 중에 엉뚱한 값을 읽는 오류가 난다. Rust는 컴파일 시점에 이 문제를 막는다. 그 대가로 가끔 우리가 관계를 글로 적어 줘야 한다.
수명 주석이 필요한 순간
참조는 대상보다 오래 살 수 없다
참조는 어떤 값을 가리키는 주소다. 가리키는 값이 먼저 정리되면 그 참조는 이미 없어진 자리를 가리키게 된다. 빌림 검사기(borrow checker)는 모든 참조에 “이 참조가 유효한 코드 구간”을 계산해 두고, 참조가 대상의 생존 구간을 벗어나 쓰이면 컴파일을 거부한다. 함수 안에서는 컴파일러가 이 구간을 직접 추적한다. 문제는 함수 경계다. 시그니처만 보고 호출하는 쪽을 검사해야 하므로, 반환된 참조가 어떤 인자에서 왔는지 시그니처에 드러나야 한다.
두 분류 이름 중 글자 수가 많은 쪽을 돌려주는 함수를 써 보자.
fn pick_longer(a: &str, b: &str) -> &str {
if b.chars().count() > a.chars().count() { b } else { a }
}
이 코드는 컴파일되지 않는다. 오류 메시지의 요지는 다음과 같다.
error[E0106]: missing lifetime specifier
this function's return type contains a borrowed value,
but the signature does not say whether it is borrowed from `a` or `b`
메시지 그대로다. 반환값이 a에서 왔는지 b에서 왔는지는 실행해 봐야 알 수 있고, 컴파일러는 시그니처만으로 판단해야 한다. 그래서 이름표를 붙인다.
fn pick_longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if b.chars().count() > a.chars().count() { b } else { a }
}
<'a> 는 “수명 매개변수 'a 를 하나 선언한다”는 뜻이다. 이름은 아무거나 되지만 관례적으로 'a, 'b 를 쓴다. &'a str 은 “'a 구간 동안 유효한 문자열 참조”로 읽는다. 세 곳에 같은 이름을 붙였으므로 시그니처는 “반환값은 두 인자가 함께 유효한 구간 안에서만 쓸 수 있다”는 약속이 된다.
수명 주석은 참조가 사는 기간을 바꾸지 않는다. 이미 정해진 기간들 사이의 관계를 컴파일러에게 설명할 뿐이다. 그래서 주석을 붙인다고 위험한 코드가 통과되지는 않는다. 아래 그림은 pick_longer 의 결과를 쓸 수 있는 구간이 두 인자의 생존 구간이 겹치는 부분이라는 것을 보여 준다.
짧게 사는 인자가 결과를 제한한다
주석이 있어도 호출하는 쪽이 약속을 어기면 오류가 난다.
fn main() {
let outer = String::from("식비");
let result;
{
let inner = String::from("문화생활");
result = pick_longer(&outer, &inner);
}
println!("{result}");
}
error[E0597]: `inner` does not live long enough
`inner` dropped here while still borrowed
borrow later used here (println 에서 result 를 사용)
inner 는 안쪽 중괄호가 끝나면 정리된다. result 가 inner 를 가리킬 수 있는 함수의 반환값이므로, 컴파일러는 중괄호 밖의 println 사용을 막는다. 실제로는 outer 가 더 길지 않을 수도 있고, 컴파일러는 그 경우를 구분하지 못한다. 시그니처에 적힌 약속만 믿고 검사하기 때문이다.
수명 생략 규칙
앞 장까지 참조를 받는 함수를 여러 개 썼지만 수명 주석을 쓴 적은 없다. 컴파일러가 흔한 패턴에서는 주석을 자동으로 채워 넣기 때문이다. 이를 수명 생략(lifetime elision)이라 한다. 규칙은 세 가지다.
| 순서 | 규칙 | 적용 예 | 결과 |
|---|---|---|---|
| 1 | 참조 매개변수마다 서로 다른 수명을 붙인다 | fn f(a: &str, b: &str) | 'a, 'b 가 각각 생긴다 |
| 2 | 입력 수명이 정확히 하나면 모든 출력 참조에 그 수명을 준다 | fn f(a: &str) -> &str | 출력은 a 와 같은 수명 |
| 3 | 매개변수에 &self 나 &mut self 가 있으면 출력에 self 의 수명을 준다 | fn title(&self) -> &str | 출력은 self 를 빌린 기간과 같다 |
규칙을 적용한 뒤에도 출력 참조의 수명이 정해지지 않으면 컴파일러는 추측하지 않고 오류를 낸다. pick_longer 는 입력 참조가 둘이고 self 도 없으니 규칙 2와 3이 모두 적용되지 않는다. 앞에서 본 E0106 이 바로 이 경우다.
반대로 아래 함수는 주석 없이 컴파일된다. 입력 참조가 line 하나뿐이어서 규칙 2가 적용된다.
fn category_head(line: &str) -> &str {
match line.split_once(':') {
Some((head, _)) => head.trim(),
None => UNCATEGORIZED,
}
}
컴파일러가 채운 시그니처는 fn category_head<'a>(line: &'a str) -> &'a str 이다. None 갈래가 돌려주는 UNCATEGORIZED 는 프로그램 전체 동안 유효한 상수라서, 더 짧은 'a 자리에도 들어갈 수 있다. 오래 사는 참조는 짧게 사는 참조 자리에 쓸 수 있다는 점을 기억해 두자.
참조를 담는 구조체와 'static
구조체 필드에 참조를 넣기
구조체 필드에 참조를 넣으려면 구조체 이름 뒤에 수명 매개변수를 선언하고 필드 타입에 붙여야 한다. 함수와 달리 구조체 필드에는 생략 규칙이 적용되지 않는다.
struct Summary {
title: &str,
}
error[E0106]: missing lifetime specifier
expected named lifetime parameter
올바른 형태는 다음과 같다.
struct Summary<'a> {
title: &'a str,
entries: &'a [Entry],
}
이 선언은 “Summary 값은 두 필드가 가리키는 값이 사는 동안에만 존재할 수 있다”는 뜻이다. 아래 그림처럼 Summary 는 값을 소유하지 않고 빌리기만 하므로, 원본인 title 과 entries 가 먼저 사라지지 않게 검사받는다.
메서드를 붙일 때는 impl 뒤에도 수명 매개변수를 선언한다. 제네릭 타입에 impl<T> 를 쓰던 것과 같은 방식이다.
impl<'a> Summary<'a> {
fn title(&self) -> &str { self.title }
fn biggest(&self) -> Option<&'a Entry> { /* ... */ }
}
두 메서드의 반환 수명이 다르다는 데 주목하자. title 은 규칙 3에 따라 &self 를 빌린 기간에 묶인다. biggest 는 명시적으로 'a 를 써서, Summary 자체가 아니라 원본 목록이 사는 기간 동안 쓸 수 있는 참조를 돌려준다. 그 덕분에 요약 객체를 이미 정리한 뒤에도 그 참조를 계속 쓸 수 있다. 반환 참조를 필드 수명에 묶을 수 있다면 'a 를 쓰는 편이 호출하는 쪽에 더 많은 자유를 준다.
'static 이 뜻하는 것
'static 은 이름이 정해진 특별한 수명으로, 프로그램이 끝날 때까지 유효할 수 있다는 뜻이다. 대표적인 경우가 소스 코드에 직접 적은 문자열 리터럴이다. 리터럴은 실행 파일 안에 들어 있어서 프로그램이 도는 동안 사라지지 않으므로 타입이 &'static str 이다. const 로 선언한 문자열 상수도 같다.
fn label(amount: i64) -> &'static str {
if amount >= 100_000 { "큰 지출" }
else if amount >= 30_000 { "보통" }
else { "소액" }
}
이 함수는 입력에 참조가 없으므로 반환 참조가 어디서 왔는지 컴파일러가 알 수 없다. 그래서 'static 을 명시했다. 호출하는 쪽은 반환값이 어떤 변수에도 묶여 있지 않다는 것을 알고 자유롭게 보관할 수 있다.
'static 에는 흔한 오해가 두 가지 있다. 첫째, 'static 은 오류를 없애 주는 도구가 아니다. 지역 변수를 가리키는 참조를 'static 으로 바꿀 수는 없다. 둘째, 다른 곳에서 T: 'static 이라는 제약을 만나도 “영구히 산다”는 뜻이 아니다. 이 제약은 “T 안에 짧게 사는 참조가 들어 있지 않다”는 뜻이고, String 이나 Vec<i64> 처럼 참조 없이 값을 소유한 타입은 이 조건을 만족한다.
| 참조 | 가리키는 곳 | 쓸 수 있는 기간 | 예 |
|---|---|---|---|
| 리터럴 | 실행 파일 안의 데이터 | 프로그램이 끝날 때까지 ('static) | "보통" |
| 지역 변수 빌림 | 스택의 값 | 그 변수가 정리되기 전까지 | &title |
| 컬렉션 원소 빌림 | 벡터가 소유한 값 | 벡터가 바뀌거나 정리되기 전까지 | &entries[0] |
완성 코드
아래 프로그램은 "분류: 금액" 형태의 줄을 읽어 지출 목록을 만들고, 그 목록을 빌리는 요약 객체로 합계와 최대 지출을 구한다. 프로젝트 이름은 자유이며 src/main.rs 하나만 있으면 된다.
use std::collections::HashMap;
const UNCATEGORIZED: &str = "미분류";
struct Entry {
category: String,
amount: i64,
}
struct Summary<'a> {
title: &'a str,
entries: &'a [Entry],
}
impl<'a> Summary<'a> {
fn new(title: &'a str, entries: &'a [Entry]) -> Self {
Summary { title, entries }
}
fn title(&self) -> &str {
self.title
}
fn total(&self) -> i64 {
self.entries.iter().map(|e| e.amount).sum()
}
fn biggest(&self) -> Option<&'a Entry> {
self.entries.iter().max_by_key(|e| e.amount)
}
}
fn category_head(line: &str) -> &str {
match line.split_once(':') {
Some((head, _)) => head.trim(),
None => UNCATEGORIZED,
}
}
fn parse_line(line: &str) -> Option<Entry> {
let (_, tail) = line.split_once(':')?;
let amount = tail.trim().parse().ok()?;
Some(Entry {
category: category_head(line).to_string(),
amount,
})
}
fn pick_longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if b.chars().count() > a.chars().count() { b } else { a }
}
fn label(amount: i64) -> &'static str {
if amount >= 100_000 {
"큰 지출"
} else if amount >= 30_000 {
"보통"
} else {
"소액"
}
}
fn totals_by_category(entries: &[Entry]) -> HashMap<&str, i64> {
let mut map = HashMap::new();
for e in entries {
*map.entry(e.category.as_str()).or_insert(0) += e.amount;
}
map
}
fn main() {
let lines = [
"식비: 12000",
"교통: 1500",
"식비: 34000",
"메모만 있는 줄",
"문화생활: 45000",
"교통: 2500",
];
let mut entries = Vec::new();
for line in lines {
match parse_line(line) {
Some(e) => entries.push(e),
None => println!("건너뜀: {} -> {}", line, category_head(line)),
}
}
let title = String::from("9월 지출");
let summary = Summary::new(&title, &entries);
println!(
"[{}] 건수 {} / 합계 {}원",
summary.title(),
summary.entries.len(),
summary.total()
);
if let Some(big) = summary.biggest() {
println!("가장 큰 지출: {} {}원 ({})", big.category, big.amount, label(big.amount));
}
let totals = totals_by_category(&entries);
let mut names: Vec<&str> = totals.keys().copied().collect();
names.sort();
println!("분류별 합계");
let mut longest = names[0];
for &name in &names {
println!(" {}: {}원", name, totals[name]);
longest = pick_longer(longest, name);
}
println!("가장 긴 분류 이름: {}", longest);
}
줄별 해설
const UNCATEGORIZED: &str: 상수의 참조는 자동으로'static이다. 분류를 알 수 없을 때 돌려줄 기본값으로 쓴다.struct Summary<'a>: 제목과 기록 목록을 소유하지 않고 빌려 담는다. 두 필드가 같은'a를 쓰므로, 둘 중 더 짧게 사는 쪽이 요약 객체의 사용 가능 구간을 정한다.impl<'a> Summary<'a>: 메서드 정의에서'a를 쓰기 위한 선언이다.new는 받은 참조를 그대로 필드에 넣는다.fn title(&self) -> &str: 규칙 3이 적용되어 반환 참조가self를 빌린 기간에 묶인다.fn biggest(&self) -> Option<&'a Entry>: 기록 목록이 사는 기간'a동안 유효한 참조를 돌려준다. 원소가 없으면None이다.category_head:split_once가 줄을:앞뒤로 나눈다. 앞부분을 다듬어 돌려주고, 구분자가 없으면 상수를 돌려준다. 입력 참조가 하나여서 주석을 생략했다.parse_line: 구분자가 없거나 금액이 숫자가 아니면?로None을 돌려준다. 분류는to_string으로 소유 문자열이 되어Entry에 들어간다. 이 함수는 참조가 아니라 소유한Entry를 돌려주므로 수명 걱정이 없다.pick_longer: 글자 수는 바이트 수가 아니라chars().count()로 센다. 한글은 한 글자가 여러 바이트여서len()과 결과가 다르다. 길이가 같으면 앞쪽 인자를 돌려준다.label: 리터럴만 돌려주므로&'static str이다.totals_by_category: 키가&str인 맵이다. 입력 참조가 하나이므로 생략 규칙 2에 따라 키의 수명이entries에 묶인다. 키를 복제하지 않고Entry안의 문자열을 빌려 쓴다. 그래서 이 맵이 살아 있는 동안entries를 수정할 수 없다.main의 반복문: 파싱에 실패한 줄은 건너뛰며 어떤 분류로 읽혔을지 출력한다.Summary::new(&title, &entries):&String은&str로,&Vec<Entry>는&[Entry]로 자동 변환된다. 이 줄 이후title을 옮기거나entries에push하면summary를 쓰는 곳이 있는 한 오류가 난다.totals.keys().copied().collect(): 키 참조를 복사해 벡터에 모은다.HashMap의 순회 순서는 정해져 있지 않으므로sort로 정렬해서 출력을 결정적으로 만든다.for &name in &names:&name패턴이&&str에서&str을 꺼낸다. 합계를 출력하면서 가장 긴 이름도 갱신한다.
실행 결과
아래는 프로그램이 표준 출력에 내는 내용이다. cargo 가 표준 오류로 찍는 빌드 진행 줄은 생략했다. 경고는 나오지 않는다.
$ cargo run
건너뜀: 메모만 있는 줄 -> 미분류
[9월 지출] 건수 5 / 합계 95000원
가장 큰 지출: 문화생활 45000원 (보통)
분류별 합계
교통: 4000원
문화생활: 45000원
식비: 46000원
가장 긴 분류 이름: 문화생활
실무에서 자주 틀리는 것
지역 변수의 참조를 돌려준다
틀린 코드:
fn top_category(entries: &[Entry]) -> &str {
let name = String::from("식비");
&name
}
error[E0515]: cannot return reference to local variable `name`
name 은 함수가 끝나면 정리된다. 수명 주석으로 해결할 수 있는 문제가 아니다. 소유한 값을 돌려주거나, 입력에서 빌린 참조를 돌려줘야 한다.
fn top_category(entries: &[Entry]) -> Option<&str> {
entries
.iter()
.max_by_key(|e| e.amount)
.map(|e| e.category.as_str())
}
오류가 나면 'static 을 붙인다
틀린 코드:
fn print_label(text: &'static str) {
println!("[{}]", text);
}
fn main() {
let line = String::from("식비");
print_label(&line);
}
error[E0597]: `line` does not live long enough
argument requires that `line` is borrowed for `'static`
함수가 리터럴만 받게 좁혀 버린 것이다. 출력에 필요한 것은 짧은 빌림이므로 'static 을 빼면 된다. 생략 규칙이 알아서 처리한다.
fn print_label(text: &str) {
println!("[{}]", text);
}
모든 참조에 같은 이름표를 붙인다
틀린 코드:
fn strip<'a>(text: &'a str, prefix: &'a str) -> &'a str {
text.strip_prefix(prefix).unwrap_or(text)
}
fn main() {
let text = String::from("지출-식비");
let cut;
{
let prefix = String::from("지출-");
cut = strip(&text, &prefix);
}
println!("{cut}");
}
error[E0597]: `prefix` does not live long enough
결과는 text 의 일부인데, 시그니처가 prefix 와도 묶어 버렸다. 결과와 관계없는 인자에는 'a 를 붙이지 않는다.
fn strip<'a>(text: &'a str, prefix: &str) -> &'a str {
text.strip_prefix(prefix).unwrap_or(text)
}
빌려 준 원본을 먼저 정리한다
틀린 코드:
let title = String::from("9월 지출");
let entries: Vec<Entry> = Vec::new();
let summary = Summary::new(&title, &entries);
drop(title);
println!("{}", summary.title());
error[E0505]: cannot move out of `title` because it is borrowed
drop 은 값을 옮겨 정리한다. summary 가 아직 쓰이는 동안에는 원본을 건드릴 수 없다. 마지막 사용 뒤로 미루거나, 굳이 정리할 필요 없이 스코프가 끝나기를 기다린다.
println!("{}", summary.title());
drop(title);
한눈에 보기
| 문법 | 읽는 법 | 언제 쓰는가 | 예 |
|---|---|---|---|
fn f<'a> | 수명 매개변수 선언 | 입력 참조가 둘 이상이고 출력이 참조일 때 | pick_longer |
&'a T | 'a 동안 유효한 참조 | 참조 관계를 시그니처에 밝힐 때 | &'a str |
struct S<'a> | 참조 필드를 가진 구조체 | 원본을 복제하지 않고 빌려 담을 때 | Summary<'a> |
impl<'a> S<'a> | 구조체의 수명을 메서드에서 사용 | 참조 필드를 가진 구조체에 메서드를 붙일 때 | biggest |
'static | 프로그램이 끝날 때까지 유효 가능 | 리터럴이나 상수를 돌려줄 때 | label |
| 증상 | 오류 코드 | 먼저 의심할 것 | 대표 해결책 |
|---|---|---|---|
| missing lifetime specifier | E0106 | 생략 규칙이 통하지 않는 시그니처나 구조체 | 수명 이름표를 붙인다 |
| does not live long enough | E0597 | 결과가 원본보다 오래 쓰인다 | 원본 선언을 바깥으로 옮기거나 소유한 값을 돌려준다 |
| cannot return reference to local variable | E0515 | 함수 안에서 만든 값을 가리킨다 | 소유한 값을 돌려준다 |
| cannot move out ... because it is borrowed | E0505 | 빌려 준 원본을 옮기거나 정리했다 | 빌림이 끝난 뒤에 옮긴다 |
연습 문제
Entry두 개의 참조를 받아 금액이 더 큰 쪽의 참조를 돌려주는 함수larger의 시그니처를 완성하라. 본문은if a.amount >= b.amount { a } else { b }이다.- 아래 함수는 컴파일되지 않는다. 오류 코드를 예상하고 고쳐라.
fn first_category(entries: &[Entry], fallback: &str) -> &str { entries.first().map(|e| e.category.as_str()).unwrap_or(fallback) } - 분류 이름 하나를 빌려 담는 구조체
CategoryFilter를 만들고,Entry가 그 분류에 속하는지 판단하는 메서드matches를 작성하라. - 다음 코드의 문제를 설명하고 고쳐라.
fn shout(text: &'static str) -> String { text.to_uppercase() } fn main() { let input = String::from("food"); println!("{}", shout(&input)); }
정답과 해설
1번.
fn larger<'a>(a: &'a Entry, b: &'a Entry) -> &'a Entry {
if a.amount >= b.amount { a } else { b }
}
입력 참조가 둘이라 생략 규칙이 통하지 않는다. 반환값이 둘 중 어느 쪽이든 될 수 있으므로 셋 모두 같은 이름표를 쓴다.
2번. 입력 참조가 둘이고 self 가 없어서 E0106 이 난다. 반환값은 entries 원소일 수도, fallback 일 수도 있으므로 둘 다 묶는다.
fn first_category<'a>(entries: &'a [Entry], fallback: &'a str) -> &'a str {
entries.first().map(|e| e.category.as_str()).unwrap_or(fallback)
}
3번.
struct CategoryFilter<'a> {
name: &'a str,
}
impl<'a> CategoryFilter<'a> {
fn new(name: &'a str) -> Self {
CategoryFilter { name }
}
fn matches(&self, entry: &Entry) -> bool {
entry.category == self.name
}
}
matches 는 불리언만 돌려주므로 반환 수명 걱정이 없다. String 과 &str 은 == 로 바로 비교할 수 있다.
4번. shout 가 'static 참조만 받게 되어 있는데 input 은 지역 변수라서 E0597 이 난다. 함수는 문자열을 잠깐 읽고 새 String 을 만들 뿐이므로 'static 이 필요 없다. 아래처럼 고치면 리터럴과 String 의 빌림을 모두 받을 수 있다.
fn shout(text: &str) -> String {
text.to_uppercase()
}
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.