라이프타임과 참조 유효성
대여 검사기가 참조의 수명을 비교하는 원리를 익히고 함수·구조체·메서드에 라이프타임 관계를 명시해 댕글링을 막습니다.
라이프타임(lifetime)은 이미 써 온 또 다른 종류의 제네릭입니다. 타입이 원하는 동작을 하도록 보장하는 트레이트와 달리, 라이프타임은 참조가 필요한 동안 유효하도록 보장합니다. 참조와 대여에서 다루지 않은 것이 있는데, 러스트의 모든 참조에는 그 참조가 유효한 범위인 라이프타임이 있다는 점입니다. 타입을 대부분 추론하듯 라이프타임도 대개 저절로 추론되므로 적을 필요가 없습니다. 타입이 여러 가지일 수 있을 때만 타입 표기를 하듯이, 참조의 라이프타임이 여러 방식으로 관련될 수 있을 때만 라이프타임을 표기합니다.
러스트는 실행 중에 쓰는 실제 참조가 반드시 유효하도록, 제네릭 라이프타임 매개변수로 참조 사이의 관계를 표기하라고 요구합니다. 라이프타임 표기는 다른 언어에는 없는 개념이라 낯설 수 있습니다. 이 글에서 라이프타임을 모두 다루지는 않지만, 흔히 만나는 라이프타임 문법을 보며 개념에 익숙해지도록 합니다.
라이프타임으로 댕글링 참조 방지하기
라이프타임의 주된 목적은 프로그램이 의도하지 않은 데이터를 가리키는 댕글링 참조(dangling reference)를 막는 것입니다. 다음 프로그램은 바깥 스코프와 안쪽 스코프를 가집니다. 이 코드는 컴파일되지 않습니다.
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {}", r);
}바깥 스코프는 처음 값 없이 r이라는 변수를 선언하고, 안쪽 스코프는 처음 값이 5인 x를 선언한 뒤 r에 x의 참조를 넣습니다. 안쪽 스코프가 끝난 뒤 r의 값을 출력하려 하는데, r이 가리키는 값이 쓰기 전에 이미 스코프를 벗어났으므로 컴파일되지 않습니다. 러스트에는 null 값이 없으므로, 값을 넣기 전에 r을 쓰려 하면 컴파일 오류가 나 처음 값 없는 변수를 잘못 쓸 수는 없습니다.
error[E0597]: `x` does not live long enough
--> src/main.rs:6:13
|
5 | let x = 5;
| - binding `x` declared here
6 | r = &x;
| ^^ borrowed value does not live long enough
7 | }
| - `x` dropped here while still borrowed
8 |
9 | println!("r: {}", r);
| - borrow later used here오류 메시지는 x가 충분히 오래 살지 않는다고 알려 줍니다. 7번째 줄에서 안쪽 스코프가 끝날 때 x가 스코프를 벗어나는데, r은 바깥 스코프에서 여전히 유효하고 9번째 줄에서 쓰이기 때문입니다. 이 코드가 허용된다면 r은 x가 스코프를 벗어날 때 해제된 메모리를 가리키게 되고, r으로 하는 일은 올바르게 동작하지 않을 것입니다. 러스트는 이 코드가 올바르지 않다는 것을 대여 검사기(borrow checker)로 알아냅니다.
대여 검사기
러스트 컴파일러의 대여 검사기는 스코프를 비교해 모든 대여가 유효한지 확인합니다. 앞의 코드에 r과 x의 라이프타임을 주석으로 붙이면 다음과 같습니다.
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
} // ---------+r의 라이프타임을 'a, x의 라이프타임을 'b로 표시했습니다. 안쪽 'b 블록은 바깥 'a 블록보다 훨씬 짧습니다. 컴파일할 때 러스트는 두 라이프타임의 길이를 비교해, r의 라이프타임이 'a인데 그것이 가리키는 메모리의 라이프타임은 'b라는 것을 봅니다. 대여한 것의 라이프타임 'b가 빌린 쪽의 라이프타임 'a보다 짧으므로 프로그램을 거부합니다. 다음은 댕글링 참조가 없어 컴파일되는 코드입니다.
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {}", r); // | |
// --+ |
} // ----------+r: 5여기서 x의 라이프타임 'b는 'a보다 깁니다. 그래서 x가 유효한 동안 r의 참조도 언제나 유효하다는 것을 러스트가 알 수 있으므로, r이 x를 가리킬 수 있습니다. 이때 대여 검사기가 실제로 비교하는 것은 변수가 선언된 블록 전체가 아니라, 참조가 실제로 쓰이는 구간입니다. 아래 그림은 같은 x와 r로, 소유자가 끝나기 전에 참조를 마지막으로 쓰면 통과하고 끝난 뒤에 쓰려 하면 거부되는 두 경우를 비교합니다.
Rust NLL의 실제 사용 범위와 소유자 유효성
x 생성·r 대여·마지막 사용·x 종료의 소스 사건 순서로 대여가 소유자 범위 안에 드는지 확인한다. 거부 소스의 종료 뒤 사용은 실행되지 않는 컴파일 실패이며 NLL이 소유자 수명을 늘리지 않는 조건을 단계에 둔다.
NLL · OWNER VALIDITY · ACTUAL USE RANGE
NLL은 블록 중첩 대신 소유자 범위와 실제 참조 사용 구간을 비교한다
이 직선 흐름 예제에서는 첫 대여부터 마지막 사용까지가 필요한 대여 구간입니다. 변수 범위가 길어도 대여는 끝까지 이어지지 않습니다. 소유자가 끝난 뒤 쓰면 거부됩니다.
소유자 종료 뒤에 참조를 사용
x생성참조 대상이 유효해집니다.
r = &x대여가 시작됩니다.
x종료참조 대상이 더는 유효하지 않습니다.
r마지막 사용대여 구간이 소유자 범위를 벗어나 거부됩니다.
참조의 마지막 사용이 먼저 끝남
x생성참조 대상이 유효해집니다.
r = &x대여가 시작됩니다.
r마지막 사용NLL의 실제 사용 구간은 여기서 끝납니다.
x종료대상이 그때까지 유효했으므로 통과합니다.
블록 주석은 근사입니다. 실제 판정은 제어 흐름에서 참조·소유자 범위를 따집니다. NLL은 대여만 줄이고 소유자 수명은 그대로입니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 소스의 사건 순서를 나타내며 눈금 간격은 실제 경과 시간이 아니다. 거부 사례의 사건은 x 생성, r 대여, x 종료, r 마지막 사용 순서이고 회색 소유자 범위보다 강조 대여 범위가 뒤로 나온다. 통과 사례는 r 마지막 사용이 x 종료보다 먼저 있어 대여 범위가 소유자 범위에 포함된다. |
| 노드·연결선 라벨 | 왼쪽→오른쪽: 사건 순서 · 간격≠실제 경과 시간 |
| 관계·조건 설명 | 소스의 사건 순서를 나타내며 눈금 간격은 실제 경과 시간이 아니다. 거부 사례의 사건은 x 생성, r 대여, x 종료, r 마지막 사용 순서이고 회색 소유자 범위보다 강조 대여 범위가 뒤로 나온다. 통과 사례는 r 마지막 사용이 x 종료보다 먼저 있어 대여 범위가 소유자 범위에 포함된다. 모바일은 사건 순서를 위에서 아래로 펼친다. 위는 거부, 아래는 통과이며 각 회색·강조 구간의 시작과 종료 사건을 그대로 보존한다. |
| 노드·연결선 라벨 | 간격≠실제 경과 시간 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 소유자·대여 x를 만든 뒤 r에 참조자를 저장한다. |
|
|
| 마지막 읽기 통과 사본은 x 종료 전에 r를 마지막으로 쓴다. |
|
|
| 거부 사본 대조 x 종료 뒤 r 사용을 요구하면 컴파일이 거부된다. |
|
|
Rust NLL의 실제 사용 범위와 소유자 유효성
x 생성·r 대여·마지막 사용·x 종료의 소스 사건 순서로 대여가 소유자 범위 안에 드는지 확인한다. 거부 소스의 종료 뒤 사용은 실행되지 않는 컴파일 실패이며 NLL이 소유자 수명을 늘리지 않는 조건을 단계에 둔다.
NLL · OWNER VALIDITY · ACTUAL USE RANGE
NLL은 블록 중첩 대신 소유자 범위와 실제 참조 사용 구간을 비교한다
이 직선 흐름 예제에서는 첫 대여부터 마지막 사용까지가 필요한 대여 구간입니다. 변수 범위가 길어도 대여는 끝까지 이어지지 않습니다. 소유자가 끝난 뒤 쓰면 거부됩니다.
소유자 종료 뒤에 참조를 사용
x생성참조 대상이 유효해집니다.
r = &x대여가 시작됩니다.
x종료참조 대상이 더는 유효하지 않습니다.
r마지막 사용대여 구간이 소유자 범위를 벗어나 거부됩니다.
참조의 마지막 사용이 먼저 끝남
x생성참조 대상이 유효해집니다.
r = &x대여가 시작됩니다.
r마지막 사용NLL의 실제 사용 구간은 여기서 끝납니다.
x종료대상이 그때까지 유효했으므로 통과합니다.
블록 주석은 근사입니다. 실제 판정은 제어 흐름에서 참조·소유자 범위를 따집니다. NLL은 대여만 줄이고 소유자 수명은 그대로입니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 소스의 사건 순서를 나타내며 눈금 간격은 실제 경과 시간이 아니다. 거부 사례의 사건은 x 생성, r 대여, x 종료, r 마지막 사용 순서이고 회색 소유자 범위보다 강조 대여 범위가 뒤로 나온다. 통과 사례는 r 마지막 사용이 x 종료보다 먼저 있어 대여 범위가 소유자 범위에 포함된다. |
| 노드·연결선 라벨 | 왼쪽→오른쪽: 사건 순서 · 간격≠실제 경과 시간 |
| 관계·조건 설명 | 소스의 사건 순서를 나타내며 눈금 간격은 실제 경과 시간이 아니다. 거부 사례의 사건은 x 생성, r 대여, x 종료, r 마지막 사용 순서이고 회색 소유자 범위보다 강조 대여 범위가 뒤로 나온다. 통과 사례는 r 마지막 사용이 x 종료보다 먼저 있어 대여 범위가 소유자 범위에 포함된다. 모바일은 사건 순서를 위에서 아래로 펼친다. 위는 거부, 아래는 통과이며 각 회색·강조 구간의 시작과 종료 사건을 그대로 보존한다. |
| 노드·연결선 라벨 | 간격≠실제 경과 시간 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 소유자·대여 x를 만든 뒤 r에 참조자를 저장한다. |
|
|
| 마지막 읽기 통과 사본은 x 종료 전에 r를 마지막으로 쓴다. |
|
|
| 거부 사본 대조 x 종료 뒤 r 사용을 요구하면 컴파일이 거부된다. |
|
|
참조를 만든 곳부터 마지막으로 쓰는 곳까지가 대여가 필요한 구간이며, 그 구간이 소유자가 유효한 범위 안에 들어 있어야 합니다. 그래서 다음 코드는 view라는 이름이 main 끝까지 있어도 컴파일됩니다.
fn main() {
let mut text = String::from("hello");
let view = &text;
println!("before: {}", view); // view의 마지막 사용
text.push_str("!");
println!("after: {}", text);
}before: hello
after: hello!view는 첫 println!에서 마지막으로 쓰이므로, 그 뒤의 push_str가 text를 가변으로 빌릴 때 대여가 겹치지 않습니다. 참조의 라이프타임이 어디에 있고 러스트가 그것을 어떻게 분석하는지 보았으니, 이제 함수의 매개변수와 반환 값의 제네릭 라이프타임을 봅니다.
함수에서의 제네릭 라이프타임
두 문자열 슬라이스 가운데 긴 쪽을 돌려주는 함수를 만들어 봅니다. 이 함수는 문자열 슬라이스 둘을 받아 하나를 돌려주며, 함수를 구현하면 다음 코드가 The longest string is abcd를 출력해야 합니다.
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}함수가 매개변수의 소유권을 가져가지 않도록 문자열이 아니라 문자열 슬라이스, 곧 참조를 받습니다. 그러면 String의 슬라이스든 문자열 리터럴이든 넘길 수 있습니다. 매개변수를 왜 이렇게 정하는지는 문자열 슬라이스를 매개변수로 사용하기를 참고하세요. 그런데 다음처럼 longest를 구현하면 컴파일되지 않습니다.
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++도움말은 반환 타입에 제네릭 라이프타임 매개변수가 필요하다고 알려 줍니다. 돌려주는 참조가 x를 가리키는지 y를 가리키는지 러스트가 알 수 없기 때문입니다. 사실 우리도 모릅니다. 이 함수의 본문에서 if 블록은 x의 참조를, else 블록은 y의 참조를 돌려주기 때문입니다. 함수를 정의할 때는 어떤 값이 넘어올지 모르므로 if와 else 가운데 어느 쪽이 실행될지 모르고, 넘어올 참조의 구체적인 라이프타임도 모르므로 앞에서처럼 스코프를 보고 돌려줄 참조가 언제나 유효한지 판단할 수도 없습니다. 대여 검사기도 x와 y의 라이프타임이 반환 값의 라이프타임과 어떻게 관련되는지 모르므로 판단할 수 없습니다. 이 오류를 고치려면 참조 사이의 관계를 정의하는 제네릭 라이프타임 매개변수를 더해, 대여 검사기가 분석할 수 있게 합니다.
라이프타임 명시 문법
라이프타임 표기는 참조가 얼마나 오래 사는지를 바꾸지 않습니다. 여러 참조의 라이프타임이 서로 어떻게 관련되는지를 라이프타임에 영향을 주지 않고 나타낼 뿐입니다. 시그니처에 제네릭 타입 매개변수를 적으면 함수가 어떤 타입이든 받을 수 있듯이, 제네릭 라이프타임 매개변수를 적으면 함수는 어떤 라이프타임의 참조든 받을 수 있습니다.
라이프타임 표기는 조금 낯선 문법으로, 라이프타임 매개변수의 이름은 작은따옴표 '로 시작하고 보통 제네릭 타입처럼 아주 짧은 소문자로 짓습니다. 대부분은 첫 라이프타임 표기에 'a라는 이름을 씁니다. 라이프타임 매개변수는 참조의 & 뒤에 적고, 공백으로 참조의 타입과 나눕니다. 다음은 라이프타임 매개변수가 없는 i32 참조, 'a라는 라이프타임 매개변수가 있는 i32 참조, 같은 라이프타임 'a가 있는 i32의 가변 참조입니다.
&i32 // 참조
&'a i32 // 명시적인 라이프타임이 있는 참조
&'a mut i32 // 명시적인 라이프타임이 있는 가변 참조라이프타임 표기 하나만으로는 큰 뜻이 없습니다. 표기는 여러 참조의 제네릭 라이프타임 매개변수가 서로 어떻게 관련되는지 러스트에 알려 주기 위한 것이기 때문입니다. longest 함수의 맥락에서 라이프타임 표기가 서로 어떻게 관련되는지 봅니다.
함수 시그니처에서 라이프타임 명시하기
함수 시그니처에서 라이프타임 표기를 쓰려면, 제네릭 타입 매개변수처럼 함수 이름과 매개변수 목록 사이의 꺾쇠괄호 안에 제네릭 라이프타임 매개변수를 선언합니다. 이 시그니처가 나타내려는 제약은, 돌려주는 참조가 두 매개변수가 모두 유효한 동안 유효하다는 것, 곧 매개변수와 반환 값의 라이프타임 사이의 관계입니다. 라이프타임의 이름을 'a로 하고 모든 참조에 붙입니다.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}이제 앞의 main과 함께 쓰면 컴파일되고 원하는 결과를 냅니다. 바뀐 것은 시그니처뿐이고 본문은 그대로입니다. 라이프타임 표기는 참조가 사는 기간을 바꾸지 않고 참조 사이의 관계만 알려 주므로, 실행 결과도 처음에 의도했던 그대로입니다.
The longest string is abcd이 시그니처는 어떤 라이프타임 'a에 대해, 함수가 적어도 'a만큼 사는 문자열 슬라이스 두 개를 받고, 적어도 'a만큼 사는 문자열 슬라이스를 돌려준다고 러스트에 알려 줍니다. 실제로는 longest가 돌려주는 참조의 라이프타임이, 함수에 넘긴 값이 가리키는 라이프타임 가운데 짧은 쪽과 같다는 뜻입니다. 대여 검사기가 분석에 쓰기를 바라는 관계가 바로 이것입니다.
함수 시그니처에 라이프타임 매개변수를 적어도 넘기거나 돌려받는 값의 라이프타임은 바뀌지 않습니다. 대여 검사기가 이 제약을 지키지 않는 값을 거부해야 한다고 알릴 뿐입니다. longest는 x와 y가 정확히 얼마나 오래 사는지 알 필요가 없고, 이 시그니처를 만족하는 어떤 스코프가 'a를 대신할 수 있다는 것만 알면 됩니다. 함수에 라이프타임을 표기할 때 표기는 본문이 아니라 시그니처에 들어가며, 시그니처의 타입처럼 함수의 약속의 일부가 됩니다. 시그니처에 라이프타임이 담겨 있으면 컴파일러가 분석하기 쉽고, 문제가 있을 때 오류가 우리 코드의 어느 부분과 어떤 제약을 가리키는지도 더 정확해집니다.
구체적인 참조를 longest에 넘기면 'a에 들어가는 구체적인 라이프타임은 x의 스코프와 y의 스코프가 겹치는 부분, 곧 둘 가운데 짧은 쪽입니다. 그래서 돌려받은 참조도 그 짧은 라이프타임 동안 유효합니다. 라이프타임이 서로 다른 참조를 넘겨 이 표기가 longest를 어떻게 제한하는지 봅니다.
fn main() {
let string1 = String::from("long string is long");
{
let string2 = String::from("xyz");
let result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {result}");
}
}The longest string is long string is longstring1은 바깥 스코프 끝까지, string2는 안쪽 스코프 끝까지 유효하고, result는 안쪽 스코프 끝까지 유효한 무언가를 가리킵니다. 대여 검사기는 이 코드를 허용하며 원하는 결과가 나옵니다. 이번에는 result를 쓰는 곳을 안쪽 스코프 밖으로 옮깁니다. 이 코드는 컴파일되지 않습니다.
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {}", result);
}error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
5 | let string2 = String::from("xyz");
| ------- binding `string2` declared here
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("The longest string is {}", result);
| ------ borrow later used hereprintln!에서 result가 유효하려면 string2가 바깥 스코프 끝까지 유효해야 한다는 오류입니다. 함수의 매개변수와 반환 값에 같은 라이프타임 매개변수 'a를 적었으므로 러스트가 이를 압니다. 사람이 보면 string1이 string2보다 길어 result는 string1을 가리킬 것이므로 string1이 아직 유효한 println!에서 써도 될 것 같습니다. 하지만 컴파일러는 이 경우의 참조가 유효한지 알 수 없습니다. 우리는 longest가 돌려주는 참조의 라이프타임이 넘긴 참조의 라이프타임 가운데 짧은 쪽과 같다고 알렸으므로, 대여 검사기는 이 코드가 올바르지 않은 참조를 가질 수 있다고 보고 거부합니다.
여기서 긴 쪽은 len이 세는 UTF-8 바이트 수를 기준으로 정합니다. 그래서 longest("rust", "café")는 4바이트보다 5바이트가 길므로 "café"를 돌려주며, 바이트 수가 같으면 >가 거짓이 되어 둘째 매개변수를 돌려줍니다. 어떤 문자열을 고르는지는 비교의 규칙이고, 참조가 유효한지는 라이프타임의 문제라는 점을 나누어 생각합니다.
라이프타임의 측면에서 생각하기
라이프타임 매개변수를 어떻게 적을지는 함수가 하는 일에 따라 다릅니다. 예를 들어 longest가 언제나 가장 긴 문자열 대신 첫 매개변수를 돌려주도록 바꾼다면, y 매개변수에는 라이프타임을 적을 필요가 없습니다.
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}x와 반환 타입에만 'a를 적었습니다. y의 라이프타임은 x나 반환 값의 라이프타임과 관련이 없기 때문입니다. 함수가 참조를 돌려줄 때, 반환 타입의 라이프타임 매개변수는 매개변수 가운데 하나의 라이프타임과 맞아야 합니다. 돌려주는 참조가 매개변수 가운데 하나를 가리키지 않는다면, 함수 안에서 만든 값을 가리키는 것인데, 그 값은 함수가 끝날 때 스코프를 벗어나므로 댕글링 참조가 됩니다. 다음 longest는 컴파일되지 않습니다.
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}error[E0515]: cannot return value referencing local variable `result`
--> src/main.rs:11:5
|
11 | result.as_str()
| ------^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| `result` is borrowed here반환 타입에 라이프타임 매개변수 'a를 적었지만, 돌려주는 값의 라이프타임이 매개변수의 라이프타임과 전혀 관련이 없어 컴파일되지 않습니다. result는 longest가 끝날 때 스코프를 벗어나 정리되는데, 함수에서 result의 참조를 돌려주려 하기 때문입니다. 라이프타임 매개변수를 어떻게 적어도 이 댕글링 참조를 바꿀 수는 없으며, 러스트는 댕글링 참조를 만들지 못하게 합니다. 이 경우에는 참조 대신 소유한 데이터 String을 돌려주어, 부른 쪽이 그 값을 정리하게 하는 것이 가장 좋은 해결책입니다.
결국 라이프타임 문법은 함수의 여러 매개변수와 반환 값의 라이프타임을 서로 잇는 것입니다. 이어 두면 러스트는 메모리를 안전하게 다루는 연산은 허용하고, 댕글링 포인터를 만들거나 메모리 안전성을 해치는 연산은 거부할 만큼의 정보를 얻습니다.
구조체 정의에서 라이프타임 명시하기
지금까지 정의한 구조체는 모두 소유한 타입을 담았습니다. 구조체에 참조를 담을 수도 있지만, 그러려면 구조체 정의의 모든 참조에 라이프타임 표기를 붙여야 합니다. 다음 ImportantExcerpt는 문자열 슬라이스를 담습니다.
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};
}이 구조체에는 문자열 슬라이스, 곧 참조인 part 필드 하나가 있습니다. 제네릭 데이터 타입처럼 구조체 이름 뒤의 꺾쇠괄호 안에 제네릭 라이프타임 매개변수를 선언해야 구조체 정의의 본문에서 쓸 수 있습니다. 이 표기는 ImportantExcerpt의 인스턴스가 part 필드가 가리키는 참조보다 오래 살 수 없다는 뜻입니다. main은 novel이 소유한 String의 첫 문장을 가리키는 참조로 ImportantExcerpt의 인스턴스를 만듭니다. novel의 데이터는 인스턴스를 만들기 전부터 있고, 인스턴스가 스코프를 벗어난 뒤에야 스코프를 벗어나므로, 인스턴스의 참조는 유효합니다.
라이프타임 생략
모든 참조에는 라이프타임이 있고, 참조를 쓰는 함수나 구조체에는 라이프타임 매개변수를 적어야 한다는 것을 보았습니다. 하지만 슬라이스에서 본 다음 함수는 라이프타임 표기 없이도 컴파일됩니다.
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}이 함수가 표기 없이 컴파일되는 데는 역사적인 까닭이 있습니다. 러스트의 초기 버전에서는 모든 참조에 라이프타임을 적어야 했으므로 이 시그니처는 fn first_word<'a>(s: &'a str) -> &'a str이었습니다. 러스트 코드를 많이 쓰다 보니, 특정한 경우에는 러스트 프로그래머가 같은 라이프타임 표기를 되풀이한다는 것이 드러났습니다. 그런 경우는 예측할 수 있고 정해진 패턴을 따랐으므로, 러스트 팀은 그 패턴을 컴파일러에 넣어 대여 검사기가 표기 없이도 라이프타임을 추론하게 했습니다.
컴파일러에 넣은 이 패턴을 라이프타임 생략 규칙(lifetime elision rules)이라고 합니다. 프로그래머가 따라야 하는 규칙이 아니라, 컴파일러가 따져 보는 몇 가지 특정한 경우로, 코드가 그 경우에 맞으면 라이프타임을 따로 적지 않아도 됩니다. 생략 규칙이 완전한 추론을 해 주는 것은 아니어서, 규칙을 적용한 뒤에도 참조의 라이프타임이 모호하면 컴파일러는 추측하지 않고 오류를 내며, 그때는 라이프타임을 직접 적어 고칩니다.
함수나 메서드의 매개변수에 있는 라이프타임을 입력 라이프타임(input lifetime), 반환 값에 있는 라이프타임을 출력 라이프타임(output lifetime)이라고 합니다. 컴파일러는 명시적인 표기가 없을 때 다음 세 규칙으로 참조의 라이프타임을 알아냅니다. 첫째 규칙은 입력 라이프타임에, 둘째와 셋째 규칙은 출력 라이프타임에 적용되며, 세 규칙을 적용한 뒤에도 라이프타임을 알 수 없는 참조가 남으면 오류를 냅니다. 이 규칙은 fn 정의와 impl 블록에 적용됩니다.
| 규칙 | 내용 |
|---|---|
| 1 | 참조인 매개변수마다 저마다 다른 라이프타임 매개변수를 줌 |
| 2 | 입력 라이프타임 매개변수가 정확히 하나라면, 그 라이프타임을 모든 출력 라이프타임 매개변수에 줌 |
| 3 | 입력 라이프타임 매개변수가 여럿인데 그 가운데 하나가 &self나 &mut self라면, self의 라이프타임을 모든 출력 라이프타임 매개변수에 줌 |
첫째 규칙에 따라 fn foo(x: &i32)는 fn foo<'a>(x: &'a i32)가 되고, 매개변수가 둘이면 fn foo<'a, 'b>(x: &'a i32, y: &'b i32)처럼 각자 다른 라이프타임을 받습니다. 셋째 규칙 덕분에 메서드는 대부분 라이프타임을 적지 않아도 됩니다. 컴파일러가 된 것처럼 first_word에 규칙을 적용해 봅니다. 시그니처는 처음에 라이프타임이 없는 fn first_word(s: &str) -> &str입니다.
첫째 규칙에 따라 매개변수가 각자 라이프타임을 받으므로 fn first_word<'a>(s: &'a str) -> &str가 됩니다. 입력 라이프타임이 하나이므로 둘째 규칙이 적용되어, 그 입력의 라이프타임이 출력에 주어지고 fn first_word<'a>(s: &'a str) -> &'a str가 됩니다. 이제 시그니처의 모든 참조에 라이프타임이 있으므로, 프로그래머가 표기하지 않아도 컴파일러가 분석을 이어 갈 수 있습니다.
이번에는 라이프타임이 없던 처음의 longest, 곧 fn longest(x: &str, y: &str) -> &str에 규칙을 적용해 봅니다. 첫째 규칙에 따라 fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str가 됩니다. 입력 라이프타임이 둘이므로 둘째 규칙은 적용되지 않고, longest는 메서드가 아니어서 self가 없으므로 셋째 규칙도 적용되지 않습니다. 세 규칙을 다 거쳤는데도 반환 타입의 라이프타임을 알 수 없으므로 컴파일 오류가 났던 것입니다.
메서드 정의에서 라이프타임 명시하기
라이프타임이 있는 구조체에 메서드를 구현하는 문법은 제네릭 타입 매개변수와 같습니다. 라이프타임 매개변수를 어디에 선언하고 쓸지는 그것이 구조체의 필드와 관련되는지, 메서드의 매개변수와 반환 값과 관련되는지에 따라 정합니다. 구조체 필드의 라이프타임 이름은 언제나 impl 뒤에 선언하고 구조체 이름 뒤에 써야 하는데, 그 라이프타임이 구조체 타입의 일부이기 때문입니다. impl 블록 안의 메서드 시그니처에서 참조는 구조체 필드의 참조의 라이프타임과 묶일 수도, 따로일 수도 있으며, 생략 규칙 덕분에 메서드 시그니처에 라이프타임을 적을 필요가 없는 경우가 많습니다.
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}impl 뒤에 라이프타임 매개변수를 선언하고 타입 이름 뒤에 쓰는 것은 필요하지만, 첫째 생략 규칙 덕분에 self의 참조에는 라이프타임을 적지 않아도 됩니다. 셋째 생략 규칙이 적용되는 예는 다음과 같습니다.
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {announcement}");
self.part
}
}입력 라이프타임이 둘이므로 러스트는 첫째 생략 규칙에 따라 &self와 announcement에 각자 라이프타임을 줍니다. 그리고 매개변수 가운데 하나가 &self이므로, 셋째 규칙에 따라 반환 타입이 &self의 라이프타임을 받아 모든 라이프타임이 정해집니다.
정적 라이프타임
특별한 라이프타임 하나를 알아 둡니다. 'static은 그 참조가 프로그램이 실행되는 내내 살아 있을 수 있다는 뜻입니다. 모든 문자열 리터럴의 라이프타임은 'static이며, 다음처럼 표기할 수 있습니다.
let s: &'static str = "I have a static lifetime.";이 문자열의 텍스트는 프로그램의 실행 파일 안에 저장되어 언제나 쓸 수 있으므로, 모든 문자열 리터럴의 라이프타임은 'static입니다. 오류 메시지가 'static 라이프타임을 쓰라고 제안하는 경우가 있는데, 참조에 'static을 붙이기 전에 그 참조가 정말 프로그램 내내 살아 있는지, 그러기를 원하는지 생각해 봅니다. 그런 제안이 나오는 오류는 대개 댕글링 참조를 만들려 하거나 쓸 수 있는 라이프타임이 맞지 않아서 생기며, 그때는 'static을 붙이기보다 그 문제를 고칩니다.
더 깊이: T: 'static은 영원히 산다는 뜻이 아닙니다
참조에 붙은 'static과 바운드로 쓴 T: 'static은 뜻이 다릅니다. Rust by Example은 &'static T가 가리키는 데이터는 프로그램이 끝날 때까지 산다는 뜻이지만, T: 'static 바운드는 그 타입이 'static이 아닌 참조를 담고 있지 않다는 뜻이라고 설명합니다. 그래서 String이나 Vec<i32>처럼 데이터를 소유한 타입은 언제나 이 바운드를 만족합니다. 그 값을 받은 쪽은 원하는 만큼 오래 들고 있을 수 있지만, 언제든 버릴 수도 있습니다.
반대로 지역 변수의 참조 &i는 그 변수가 사는 범위만큼만 유효하므로 'static 바운드를 만족하지 못합니다. 스레드 동시 실행에서 볼 thread::spawn이 클로저에 'static을 요구하는 것도 이 뜻입니다. 새 스레드가 얼마나 오래 실행될지 모르므로 지역 변수를 빌린 클로저는 받지 않지만, 값을 move로 옮겨 소유한 클로저는 받습니다. 오류 메시지가 'static을 제안할 때 참조에 'static을 붙이기보다 소유한 값을 넘기는 편이 대개 맞는 고침인 것도 이 때문입니다.
제네릭 타입 매개변수, 트레이트 바운드, 라이프타임을 한 곳에 사용해 보기
제네릭 타입 매개변수와 트레이트 바운드, 라이프타임을 한 함수에서 함께 써 봅니다. 아래 함수는 이 글의 longest에, 알림 메시지를 하나 더 받아 먼저 출력하는 기능을 더한 것입니다.
use std::fmt::Display;
fn longest_with_an_announcement<'a, T>(
x: &'a str,
y: &'a str,
ann: T,
) -> &'a str
where
T: Display,
{
println!("Announcement! {ann}");
if x.len() > y.len() { x } else { y }
}앞의 longest가 두 문자열 슬라이스 가운데 긴 쪽을 돌려주었다면, 이 함수는 제네릭 타입 T의 매개변수 ann을 더 받습니다. ann은 where 절에 적은 대로 Display를 구현한 어떤 타입이든 될 수 있으며, {}로 출력하므로 Display 바운드가 필요합니다. 라이프타임도 제네릭의 한 종류이므로, 라이프타임 매개변수 'a와 제네릭 타입 매개변수 T를 함수 이름 뒤의 같은 꺾쇠괄호 안에 선언합니다.
제네릭과 라이프타임 요약
9장에서 많은 것을 다루었습니다. 제네릭 타입 매개변수, 트레이트와 트레이트 바운드, 제네릭 라이프타임 매개변수를 알았으니, 여러 상황에서 동작하면서도 중복이 없는 코드를 쓸 수 있습니다. 제네릭 타입 매개변수로 코드를 여러 타입에 쓰고, 트레이트와 트레이트 바운드로 그 타입이 필요한 동작을 하도록 보장하며, 라이프타임 표기로 그 유연한 코드에 댕글링 참조가 없도록 합니다. 이 모든 분석은 컴파일할 때 일어나므로 실행 중의 성능에는 영향이 없습니다. 아래 표는 참조를 돌려주는 함수에서 라이프타임을 어떻게 정할지 고르는 기준을 정리한 것입니다.
ORIGIN · SIGNATURE RELATION · CALL-SITE VALIDITY
라이프타임 표기는 참조 관계를 설명할 뿐, 수명을 만들거나 늘리지 않는다
입력, self, 실제 'static 데이터에서 반환 출처를 찾습니다. 이어 시그니처 관계와 호출 지점의 공통 범위를 검증합니다.
출처 식별
반환 출처가 입력,
self, 정적·전역 중 무엇인지 찾습니다.시그니처 관계
입력과 출력 또는 구조체와 필드가 공유해야 할 관계만 이름 붙입니다.
호출 지점 검증
각 입력은 선택된 라이프타임보다 오래 유효해야 하고, 출력 사용은 그 공통 범위 안에 있어야 합니다.
표현 선택
생략, 명시적 매개변수, 참조 필드 구조체,
'static, 소유값 반환 중 맞는 형태를 고릅니다.
| 상황 | 반환 출처 | 관계 표현 | 판정과 선택 |
|---|---|---|---|
| 단일 | 그 입력 | &T → &T | 생략 규칙이 출력 라이프타임을 유일한 입력에 연결할 수 있습니다. |
&self | self | &self → &T | 생략 규칙이 출력을 self의 대여에 연결합니다. |
| 복수 | 입력 후보들 | &'a T → &'a T | 명시한 'a는 구체 수명이 모두 같다는 뜻이 아닙니다. 각 입력이 선택된 'a보다 오래 유효하고, 출력은 그 공통 범위에서 사용됩니다. |
| 필드 | 필드의 참조 대상 | Struct<'a> | 인스턴스가 보관한 참조보다 오래 사용되지 않도록 타입에 관계를 유지합니다. |
| 정적 | 프로그램 전체에 유효한 데이터 | &'static T | 문자열 리터럴처럼 데이터가 실제로 전체 실행 동안 유효할 때만 입력 관계 없이 반환할 수 있습니다. |
| 지역값 | 함수 종료 때 사라지는 지역값 | 명시로 해결 불가 | E0515를 피하려면 참조가 아니라 String 같은 소유값을 반환합니다. |
유일한 참조 입력에서 반환
생략 규칙이 출력 라이프타임을 그 입력에 연결합니다.
&self에서 반환
생략 규칙이 출력 참조를 self의 대여에 연결합니다.
여러 입력 중 하나에서 반환
각 입력은 선택된 'a보다 오래 유효해야 하고, 출력은 그 공통 범위 안에서 씁니다. 입력들의 구체 수명은 달라도 됩니다.
Struct<'a>
구조체와 보관한 참조의 관계를 타입에 담습니다.
&'static T
문자열 리터럴·전역 데이터처럼 대상이 프로그램 내내 유효한 경우입니다.
지역값의 참조는 반환할 수 없음
라이프타임 이름을 붙여도 지역값은 함수 종료 때 사라집니다. String 같은 소유값을 반환합니다.
'a는 호출마다 선택되는 관계 매개변수다
두 인수에 같은 'a를 적어도 원본 수명이 같아지지는 않습니다. 각 인수는 선택된 'a보다 오래 유효해야 하고, 반환 참조는 그 범위에서만 씁니다.
T: 'static은 지역 변수의 수명을 늘리지 않는다
이 바운드는 T가 비-'static 참조를 품지 못하게 합니다. String 같은 소유 타입도 만족하지만 지역 값은 스코프 끝에 드롭될 수 있습니다.
라이프타임 표기는 컴파일 타임 관계 정보라 런타임 비용이 없습니다. 이를 제네릭, 트레이트 객체, 할당 등 다른 기능 전체가 늘 0비용이라는 뜻으로 넓히지 않습니다.
먼저 돌려주는 참조가 입력 가운데 하나에서 왔는지, self에서 왔는지, 프로그램 내내 사는 정적 데이터에서 왔는지 찾습니다. 그다음 시그니처에는 입력과 출력, 또는 구조체와 필드 사이에 필요한 관계만 라이프타임으로 나타내고, 부르는 곳에서는 넘긴 값들이 함께 유효한 범위 안에서만 결과를 쓰는지 확인합니다. 생략 규칙으로 정해지면 적지 않고, 모호하면 직접 적으며, 함수 안에서 만든 값을 가리키려 한다면 라이프타임이 아니라 소유한 값을 돌려주도록 고칩니다. 라이프타임 표기는 참조 사이의 관계를 설명할 뿐 수명을 늘려 주지 않습니다.
이 장에서 다룬 주제에는 더 배울 것이 많습니다. 트레이트 객체와 다형성에서는 트레이트를 쓰는 또 다른 방법인 트레이트 객체를, 고급 트레이트와 고급 타입에서는 트레이트와 타입 시스템의 고급 기능을 다룹니다. 아주 복잡한 경우에만 필요한 라이프타임 표기는 이 교재에서 다루지 않으므로, 그런 경우를 만나면 Rust Reference를 찾아봅니다.
정리
- 모든 참조에는 그 참조가 유효한 범위인 라이프타임이 있으며, 대여 검사기는 참조가 쓰이는 구간이 가리키는 값이 유효한 범위 안에 있는지 확인합니다.
- 반환하는 참조가 여러 입력 가운데 무엇에서 왔는지 모호하면
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str처럼 라이프타임을 표기합니다. - 라이프타임 표기는 수명을 바꾸지 않고 참조 사이의 관계만 나타내며, 같은
'a를 붙인 결과는 입력 가운데 짧은 쪽의 범위에서만 쓸 수 있습니다. - 함수 안에서 만든 값의 참조는 돌려줄 수 없으므로, 그때는 소유한 값을 돌려줍니다.
- 참조를 담는 구조체는
struct ImportantExcerpt<'a>처럼 라이프타임 매개변수를 가지며, 인스턴스는 그 참조보다 오래 살 수 없습니다. - 세 가지 생략 규칙이 맞는 경우에는 라이프타임을 적지 않아도 되며, 메서드는
&self의 라이프타임이 출력에 주어집니다. 'static은 프로그램 내내 사는 참조이며, 문자열 리터럴이 그렇습니다.
연습 문제
1. 짧은 쪽의 범위
다음 코드는 컴파일되나요? 된다면 무엇을 출력하나요?
fn main() {
let kept = String::from("outer value");
{
let short = String::from("in");
let chosen = longest(kept.as_str(), short.as_str());
println!("inside: {chosen}");
}
println!("outside: {kept}");
}해설 보기
컴파일되며 inside: outer value와 outside: outer value를 출력합니다. chosen은 kept와 short가 함께 유효한 안쪽 스코프 안에서만 쓰이므로 대여 검사기가 허용합니다. 바깥에서 출력하는 것은 chosen이 아니라 kept 자체이므로 문제가 없습니다. 만약 chosen을 안쪽 스코프 밖에서 출력하려 했다면, 실제로는 kept를 가리키더라도 short가 먼저 사라지므로 E0597 오류로 거부됩니다.
2. 생략 규칙 적용하기
다음 시그니처 가운데 라이프타임을 적지 않아도 컴파일되는 것은 무엇인가요? (가) fn trim_end(s: &str) -> &str (나) fn pick(a: &str, b: &str) -> &str (다) 구조체의 메서드 fn label(&self, prefix: &str) -> &str
해설 보기
(가)와 (다)입니다. (가)는 입력 라이프타임이 하나이므로 둘째 규칙에 따라 그 라이프타임이 출력에 주어집니다. (다)는 매개변수 가운데 &self가 있으므로 셋째 규칙에 따라 self의 라이프타임이 출력에 주어지며, 따라서 이 메서드는 prefix가 아니라 self에서 빌린 참조를 돌려줘야 합니다. (나)는 입력 라이프타임이 둘이고 self가 없어 출력의 라이프타임을 정할 수 없으므로, <'a>를 붙여 관계를 직접 적어야 합니다.
3. 고칠 방법 고르기
fn make_greeting<'a>(name: &str) -> &'a str { let s = format!("Hello, {name}"); s.as_str() }는 E0515 오류로 컴파일되지 않습니다. 'static을 붙이면 해결될까요? 어떻게 고쳐야 하나요?
해설 보기
해결되지 않습니다. s는 함수 안에서 만든 String이어서 함수가 끝나면 사라지므로, 어떤 라이프타임을 붙여도 그 참조를 돌려줄 수 없습니다. 반환 타입을 String으로 바꿔 fn make_greeting(name: &str) -> String { format!("Hello, {name}") }처럼 소유한 값을 돌려주면, 부른 쪽이 그 값을 가지고 정리합니다.
이것으로 9장을 마칩니다. 다음 장의 테스트 작성 방법에서는 코드가 의도대로 동작하는지 확인하는 자동화 테스트를 작성하는 방법을 살펴봅니다.