라이프타임 표기는 왜 필요한가
앞 챕터의 마지막 예제, make_greeting 함수를 조금 바꿔보겠습니다.
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}이 함수는 얼핏 문제없어 보입니다. 두 참조 중 더 긴 쪽을 돌려줄 뿐, 새로 값을 만들지도 않습니다. 그런데 컴파일해보면 이렇게 됩니다.
error[E0106]: missing lifetime specifier
|
1 | 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`핵심은 마지막 줄입니다. "반환값이 x에서 빌린 건지 y에서 빌린 건지 시그니처만 봐서는 알 수 없다"는 겁니다. 함수 본문을 보면 사람은 바로 알 수 있는데, 왜 컴파일러는 모른다고 할까요.
컴파일러는 본문을 보지 않는다 (시그니처만 본다)
이게 라이프타임을 이해하는 가장 중요한 전제입니다. Rust의 빌림 검사기는 함수 시그니처만 보고 호출부의 안전성을 판단하려고 합니다. 함수 본문까지 매번 들여다보고 추론하면 컴파일이 느려지는 것은 물론, 함수 내부 구현을 바꿀 때마다 그 함수를 호출하는 모든 곳의 검증 결과가 달라질 수 있습니다. 그래서 Rust는 "함수의 계약은 시그니처에 다 적어라"는 원칙을 지킵니다. 매개변수 타입과 반환 타입만 보고 호출이 안전한지 판단할 수 있어야 합니다.
longest 함수의 시그니처 fn longest(x: &str, y: &str) -> &str는 "x와 y 중 뭘 반환하는지"에 대한 정보가 전혀 없습니다. 만약 x가 함수 밖에서 이미 drop된 뒤인데 반환값이 x를 가리키고 있다면 댕글링 참조가 됩니다. 컴파일러는 이 가능성을 배제할 수 없으니 거부합니다.
'a 표기는 새로운 규칙이 아니라 설명이다
고친 코드를 보겠습니다.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let s1 = String::from("짧다");
let s2 = String::from("이건 더 긴 문자열이다");
let result = longest(&s1, &s2);
println!("더 긴 쪽: {result}");
}<'a>는 제네릭 타입 매개변수 <T>와 문법적으로 같은 자리에 있습니다. 실제로 라이프타임도 일종의 제네릭입니다 — 다만 타입이 아니라 "이 참조가 유효한 범위"를 매개변수화한 것뿐입니다. x: &'a str, y: &'a str, -> &'a str라고 쓰면 "x, y, 반환값이 전부 최소한 'a라는 공통 구간만큼은 유효하다"고 컴파일러에게 선언하는 겁니다.
중요한 건 이 표기가 참조의 실제 수명을 늘리거나 줄이지 않는다는 점입니다. x와 y가 원래 얼마나 사는지는 호출하는 쪽 코드가 이미 결정합니다. 'a는 그저 "이 함수의 반환값 수명은 입력값들의 수명 중 더 짧은 쪽을 넘지 않는다"는 관계를 컴파일러에게 알려주는 주석에 가깝습니다.
flowchart TD A["s1의 수명 구간"] --> C["두 수명의 교집합 = 'a"] B["s2의 수명 구간"] --> C C --> D["result는 'a 이내에서만 유효"]
대부분의 경우 안 써도 된다
이 챕터를 읽고 나면 "모든 참조에 라이프타임을 일일이 적어야 하나" 걱정할 수 있는데, 실제로는 거의 그렇지 않습니다. Rust 컴파일러에는 라이프타임 생략 규칙(lifetime elision rules) 이 있어서, 흔한 패턴은 자동으로 추론합니다.
fn first_word(s: &str) -> &str {
s.split_whitespace().next().unwrap_or("")
}이 함수는 라이프타임 표기가 하나도 없지만 컴파일됩니다. 매개변수가 하나뿐이고 반환값이 참조라면, "반환값의 수명은 그 매개변수의 수명과 같다"고 컴파일러가 자동으로 가정하기 때문입니다. 라이프타임 표기가 필요한 경우는 대체로 longest처럼 참조가 둘 이상 들어오고, 그중 어느 쪽을 반환할지가 실행 시점에 결정되는 함수로 한정됩니다. 실무 코드 대부분은 이런 모양이 아닙니다.
구조체에 참조를 담을 때
라이프타임이 진짜 신경 쓰이기 시작하는 지점은 구조체가 참조를 필드로 가질 때입니다.
struct Excerpt<'a> {
text: &'a str,
}
fn main() {
let novel = String::from("한때는 그렇게 시작하는 문장이 유행이었다.");
let first_sentence = novel.split('.').next().unwrap();
let excerpt = Excerpt { text: first_sentence };
println!("{}", excerpt.text);
}Excerpt<'a>는 "이 구조체의 인스턴스는 'a 동안 유효한 참조를 담고 있고, 따라서 구조체 자신도 'a보다 오래 살 수 없다"는 뜻입니다. novel이 drop된 뒤에도 excerpt를 쓰려고 하면 컴파일 에러가 납니다 — 원본 문자열이 사라졌는데 그 일부를 가리키는 구조체만 살아있는 상황을 막는 겁니다.
솔직히 말하면 초보 단계에서는 참조를 구조체에 담기보다, 그냥 String처럼 소유한 타입으로 필드를 만드는 편이 훨씬 편합니다. 참조 필드가 주는 이점(복사 없이 원본을 공유)은 성능이 실제로 중요해질 때 챙기면 됩니다. 처음부터 모든 구조체에 라이프타임을 박아 넣으려는 시도는, 얻는 것보다 디버깅에 쓰는 시간이 더 큽니다.