라이프타임 오류를 만나면 'a를 어디에 붙일지부터 찾기 쉽습니다. 순서가 반대입니다. 먼저 반환 참조가 어느 입력을 빌리는지, 그리고 그 입력이 반환 참조를 사용하는 동안 살아 있는지를 따져야 합니다. 라이프타임 표기는 이 관계를 함수 시그니처에 적는 문법일 뿐 값을 더 오래 살게 하지 않습니다.
이 글에서는 엔드포인트 URL을 고르는 작은 예제로 네 가지를 연결합니다. 생략 규칙이 통하는 시그니처, 두 입력 중 하나를 반환할 때 발생하는 E0106, 참조를 보관하는 구조체, 그리고 'static을 붙여도 지역 값 반환 문제가 해결되지 않는 이유입니다. 모든 예제는 Rust 2024 에디션을 사용합니다.
1. 빌림 관계부터 보기
참조를 반환하는 함수를 보면 이 질문부터 던져 볼까요?
- 반환값은 입력 중 어느 값을 가리킬 수 있는가?
- 그 원본 값은 반환된 참조가 사용되는 구간을 포함해 유효한가?
- 새 값을 함수 안에서 만들었다면 참조 대신 소유한 값을 반환해야 하지 않는가?
라이프타임 매개변수는 이 답을 표현합니다. 메모리를 할당하지 않고 값을 보존하지도 않습니다. 호출자가 관계를 지키지 못하면 컴파일러가 호출을 거부합니다.
2. 단일 입력의 라이프타임 생략
다음 함수는 입력 문자열의 첫 번째 경로 조각을 반환합니다.
fn first_segment(path: &str) -> &str {
path.split('/').next().unwrap_or("")
}
시그니처에는 라이프타임 표기가 없지만 참조의 유효성 검사가 빠진 것은 아닙니다. 함수 라이프타임 생략 규칙은 각 생략된 입력 참조에 서로 다른 라이프타임을 부여합니다. 입력 라이프타임이 정확히 하나라면 그 라이프타임을 모든 생략된 출력 참조에 사용합니다. 위 시그니처를 관계가 보이도록 쓰면 다음과 같습니다.
fn first_segment<'a>(path: &'a str) -> &'a str {
path.split('/').next().unwrap_or("")
}
두 함수의 계약은 같습니다. 반환된 &str은 path가 가리키는 데이터보다 오래 사용될 수 없습니다. 명시 표기가 데이터를 연장한 것도, 생략이 검사를 완화한 것도 아닙니다.
메서드에는 규칙이 하나 더 있습니다. 수신자가 &self 또는 &mut self라면 생략된 출력 라이프타임은 그 수신자 참조에 연결됩니다. 뒤에서 사용할 EndpointView::url(&self) -> &str이 별도 표기 없이 동작하는 까닭입니다.
3. 두 입력과 E0106
엔드포인트 설정이 비어 있으면 대체 URL을 쓰는 함수를 라이프타임 없이 작성하면 어떻게 될까요?
fn choose_endpoint(candidate: &str, fallback: &str) -> &str {
if candidate.is_empty() {
fallback
} else {
candidate
}
}
이 코드는 E0106으로 실패합니다. 각 입력에는 서로 다른 라이프타임이 배정되지만 출력에 연결할 근거가 없기 때문입니다. rustc 1.98.1에서 저장한 실제 진단은 다음과 같습니다.
error[E0106]: missing lifetime specifier
--> tests/ui/missing_output_lifetime.rs:1:56
|
1 | fn choose_endpoint(candidate: &str, fallback: &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 `candidate` or `fallback`
help: consider introducing a named lifetime parameter
|
1 | fn choose_endpoint<'a>(candidate: &'a str, fallback: &'a str) -> &'a str {
| ++++ ++ ++ ++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0106`.
진단은 문제를 정확히 짚습니다. 컴파일러는 본문을 보고 호출별 API 계약을 추측하지 않습니다. 반환값이 candidate에서 왔는지 fallback에서 왔는지 시그니처가 말해야 합니다.
두 입력 어느 쪽이든 반환할 수 있으므로 같은 라이프타임 매개변수로 묶습니다.
#[must_use]
pub fn choose_endpoint<'a>(candidate: &'a str, fallback: &'a str) -> &'a str {
if candidate.is_empty() {
fallback
} else {
candidate
}
}
여기서 'a는 두 문자열이 현실에서 똑같은 시점에 소멸한다는 뜻이 아닙니다. 호출 시 컴파일러는 두 빌림이 함께 유효한 구간을 'a로 선택합니다. 반환 참조도 그 겹치는 구간 안에서만 사용할 수 있습니다. 긴 쪽의 값을 짧게 만드는 일도 없고 짧은 쪽을 늘리지도 않습니다.
이 제약이 다소 보수적으로 보일 수 있습니다. 런타임 분기가 candidate를 골랐더라도 함수의 반환 타입은 두 입력 중 하나일 수 있다는 계약을 지킵니다. 분기 결과마다 다른 정적 타입을 주지 않으므로 호출자는 더 짧은 입력이 끝난 뒤 반환 참조를 사용할 수 없습니다.
4. 구조체의 라이프타임 관계
구조체가 &str을 보관하면 그 인스턴스가 원본 문자열보다 오래 남지 못한다는 계약을 타입에 넣어야 합니다.
#[derive(Debug, PartialEq, Eq)]
pub struct EndpointView<'a> {
pub name: &'a str,
pub url: &'a str,
}
impl<'a> EndpointView<'a> {
#[must_use]
pub const fn new(name: &'a str, url: &'a str) -> Self {
Self { name, url }
}
#[must_use]
pub const fn url(&self) -> &str {
self.url
}
}
EndpointView<'a>는 데이터를 소유하지 않습니다. name과 url이 가리키는 데이터를 보는 뷰입니다. 따라서 뷰를 원본 문자열이 사라진 뒤까지 보관하는 코드는 거부됩니다. url 메서드의 출력은 메서드 생략 규칙에 따라 &self에 연결됩니다.
하나의 'a를 두 필드에 쓴 것도 의도적인 API 선택입니다. 이 뷰를 사용하는 동안 두 참조가 모두 유효해야 합니다. 서로 독립된 기간을 표현해야 하는 구조체라면 EndpointView<'name, 'url>처럼 매개변수를 둘 수도 있지만 여기서는 얻는 이점이 없습니다.
5. 'static은 수리 도구가 아니다
'static 참조는 프로그램 실행 전체 동안 유효할 수 있는 데이터를 가리킵니다. 문자열 리터럴은 프로그램 바이너리에 들어 있으므로 대표적인 예입니다.
let fallback: &'static str = "https://fallback.example.com/health";
하지만 함수 안에서 만든 String의 참조에 'static을 적는다고 저장 기간이 바뀌지는 않습니다.
fn endpoint_url() -> &'static str {
let url = String::from("https://api.example.com/health");
&url
}
E0106의 제안만 보고 'static을 붙이면 이번에는 E0515가 실제 소유권 문제를 드러냅니다.
error[E0515]: cannot return reference to local variable `url`
--> tests/ui/return_local_reference.rs:3:5
|
3 | &url
| ^^^^ returns a reference to data owned by the current function
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0515`.
url은 함수가 끝날 때 해제됩니다. 'static은 그 값을 보존하라는 명령이 아니라 반환 참조가 프로그램 전체 동안 유효해야 한다는 더 강한 요구입니다. 지역 값은 그 요구를 충족하지 못합니다.
새 문자열을 함수 안에서 만든다면 보통 소유권을 반환하는 것이 맞습니다.
fn endpoint_url() -> String {
String::from("https://api.example.com/health")
}
정말 전역으로 유지할 데이터라면 문자열 리터럴, static 항목, 또는 명시적으로 설계한 전역 저장소처럼 실제 저장 기간도 그 계약에 맞아야 합니다. 메모리를 누수시켜 'static 참조를 만드는 방법도 존재하지만 일반적인 반환값 문제의 해법으로 삼으면 해제 책임을 버리는 셈입니다.
6. 진단 읽는 순서
E0106이나 빌림 오류를 만났을 때는 컴파일러가 제안한 표기를 바로 복사하기보다 다음 순서로 확인하면 빠릅니다.
- 반환 참조가 가리키는 원본을 찾습니다.
- 가능한 원본이 입력에 있다면 출력과 어떤 입력의 관계를 표현할지 정합니다.
- 원본이 지역 값이라면 참조 반환을 멈추고
String같은 소유 타입을 검토합니다. - 구조체가 참조를 저장한다면 구조체 인스턴스와 원본의 관계를 타입에 넣습니다.
- 데이터가 실제로 프로그램 전체에 존재할 때만
'static을 사용합니다.
표기는 마지막 단계에 가깝습니다. 소유자가 누구인지 정하지 않은 채 라이프타임 이름부터 늘리면 오류 메시지만 이동합니다.
7. 독립 예제와 진단 테스트
외부 의존성은 없고 Rust 2024 에디션을 사용합니다.
cd examples/article-10-lifetimes
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
실행 결과는 다음과 같습니다.
api -> https://api.example.com/health
tests/ui에는 컴파일되면 안 되는 소스 두 개와 rustc 1.98.1의 stderr 산출물이 함께 있습니다. 통합 테스트는 각 소스를 rustc --edition=2024로 컴파일하고 실패 상태, 빈 stdout, stderr의 바이트 단위 일치를 확인합니다. 덕분에 E0106 예제가 어느 날 조용히 컴파일되거나 진단이 바뀌면 테스트가 알려줍니다.
테스트 모음에는 단위 테스트 3개와 컴파일 실패 진단 테스트 2개가 있습니다. 컴파일러 진단 문구는 버전에 따라 달라질 수 있으므로 Rust 도구 체인을 갱신할 때 .stderr 파일도 실제 출력과 함께 검토해야 합니다.
라이프타임을 읽을 때 'a의 길이를 상상할 필요는 없습니다. 반환값이 무엇을 빌렸고 그 원본보다 오래 쓰이지 않는지 확인하면 됩니다. 관계가 맞으면 표기는 짧아지고 관계 자체가 불가능하면 소유권을 반환하는 쪽이 답입니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 10 프로젝트에서 확인할 수 있습니다.
답글 남기기