라이프타임으로 참조 검증하기
라이프타임으로 참조 검증하기
라이프타임은 우리가 이미 사용해 온 또 다른 종류의 제네릭이에요. 타입이 우리가 원하는 동작을 가지는지 보장하는 대신, 라이프타임은 참조가 우리가 필요로 하는 동안 유효함을 보장해요.
출처: The Rust Book
4장의 "참조와 빌림" 절에서 다루지 않았던 세부 사항 하나가 있어요. Rust의 모든 참조는 라이프타임(lifetime), 즉 그 참조가 유효한 스코프를 가진다는 것이에요. 대부분의 경우 타입이 추론되듯이, 라이프타임도 대부분 암시적이고 추론돼요. 여러 타입이 가능할 때만 타입을 어노테이션해야 하는 것처럼, 참조들의 라이프타임이 몇 가지 다른 방식으로 관련될 수 있을 때 라이프타임을 어노테이션해야 해요. Rust는 제네릭 라이프타임 매개변수를 사용해 그 관계를 어노테이션하도록 요구해서, 런타임에 실제로 사용되는 참조가 확실히 유효함을 보장해요.
라이프타임을 어노테이션하는 것은 대부분의 다른 프로그래밍 언어에는 없는 개념이어서, 처음엔 낯설게 느껴질 거예요. 이 장에서 라이프타임을 완전히 다루지는 않겠지만, 라이프타임 문법을 만나는 흔한 방식들을 논의해서 개념에 익숙해질 수 있게 해줄게요.
댕글링 참조
라이프타임의 주요 목적은 **댕글링 참조(dangling reference)**를 방지하는 거예요. 댕글링 참조가 허용되면 프로그램이 의도한 것과 다른 데이터를 참조하게 만들 수 있죠. 리스팅 10-16의 프로그램을 생각해 볼게요. 여기에는 외부 스코프와 내부 스코프가 있어요.
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {r}");
}
참고: 리스팅 10-16, 10-17, 10-23의 예시들은 변수에 초기값을 주지 않고 선언하므로, 변수 이름이 외부 스코프에 존재해요. 언뜻 보면 이것이 Rust에 null 값이 없다는 것과 충돌하는 것처럼 보일 수 있어요. 하지만 값을 주기 전에 변수를 사용하려 하면 컴파일 타임 에러가 나는데, 이것이 Rust가 실제로 null 값을 허용하지 않는다는 것을 보여줘요.
외부 스코프는 초기값이 없는 r이라는 변수를 선언하고, 내부 스코프는 초기값 5를 가진 x라는 변수를 선언해요. 내부 스코프 안에서 우리는 r의 값을 x에 대한 참조로 설정하려 해요. 그런 다음 내부 스코프가 끝나고, r의 값을 출력하려 해요. 이 코드는 컴파일되지 않는데, r이 참조하는 값이 우리가 그것을 사용하려 하기 전에 스코프를 벗어났기 때문이에요. 에러 메시지는 이래요.
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
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
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
에러 메시지는 변수 x가 "충분히 오래 살지 않았다(does not live long enough)"고 말해요. 이유는 x가 7번째 줄에서 내부 스코프가 끝날 때 스코프를 벗어나기 때문이에요. 하지만 r은 여전히 외부 스코프에서 유효하죠. 그 스코프가 더 크기 때문에 우리는 r이 "더 오래 산다(lives longer)"고 말해요. Rust가 이 코드가 동작하도록 허용한다면, r은 x가 스코프를 벗어났을 때 할당 해제된 메모리를 참조하게 되고, r로 하려는 어떤 것도 올바르게 동작하지 않을 거예요. 그렇다면 Rust는 어떻게 이 코드가 잘못된 것임을 판단할까요? **빌림 검사기(borrow checker)**를 사용해요.
빌림 검사기
Rust 컴파일러는 모든 빌림이 유효한지 판단하기 위해 스코프를 비교하는 빌림 검사기를 가지고 있어요. 리스팅 10-17은 리스팅 10-16과 같은 코드에 변수들의 라이프타임을 보여주는 어노테이션을 추가한 거예요.
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
여기서 r의 라이프타임을 'a로, x의 라이프타임을 'b로 어노테이션했어요. 보시다시피 내부 'b 블록은 외부 'a 라이프타임 블록보다 훨씬 작아요. 컴파일 타임에 Rust는 두 라이프타임의 크기를 비교해서 r이 'a의 라이프타임을 가지지만 'b의 라이프타임을 가진 메모리를 참조한다는 것을 알아채요. 'b가 'a보다 짧기 때문에 프로그램은 거부돼요. 참조의 대상이 참조만큼 오래 살지 않으니까요.
리스팅 10-18은 댕글링 참조가 없도록 코드를 고쳐서 에러 없이 컴파일하게 만들어요.
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {r}"); // | |
// --+ |
} // ----------+
여기서 x는 'b 라이프타임을 가지고, 이 경우에 'a보다 더 커요. 이는 Rust가 x가 유효한 동안 r의 참조가 항상 유효하다는 것을 알기 때문에 r이 x를 참조할 수 있다는 뜻이에요.
이제 참조들의 라이프타임이 어디에 있는지, 그리고 Rust가 참조가 항상 유효하다는 것을 보장하기 위해 라이프타임을 어떻게 분석하는지 알았으니, 함수 매개변수와 반환값에서의 제네릭 라이프타임을 탐구해 볼게요.
함수에서의 제네릭 라이프타임
두 문자열 슬라이스 중 더 긴 것을 반환하는 함수를 작성할 거예요. 이 함수는 두 문자열 슬라이스를 받아 하나의 문자열 슬라이스를 반환할 거예요. longest 함수를 구현하고 나면, 리스팅 10-19의 코드는 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}");
}
우리가 longest 함수가 매개변수의 소유권을 갖지 않기를 원하므로, 문자열 대신 문자열 슬라이스(참조)를 받도록 함수를 만들고 싶다는 점을 주목하세요. 리스팅 10-19에서 우리가 사용하는 매개변수가 왜 우리가 원하는 것인지에 대한 더 많은 논의는 4장의 "문자열 슬라이스를 매개변수로"를 참고하세요.
리스팅 10-20처럼 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 }
}
대신에 우리는 라이프타임에 대해 이야기하는 다음 에러를 얻게 돼요.
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
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 {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
도움말 텍스트는 반환 타입에 제네릭 라이프타임 매개변수가 필요하다는 것을 알려줘요. Rust가 반환되는 참조가 x를 가리키는지 y를 가리키는지 알 수 없기 때문이에요. 사실 우리도 모르죠. 이 함수 본문의 if 블록은 x에 대한 참조를 반환하고 else 블록은 y에 대한 참조를 반환하니까요!
이 함수를 정의할 때는 함수에 전달될 구체적인 값을 모르므로 if 경우와 else 경우 중 무엇이 실행될지 모르죠. 또한 전달될 참조들의 구체적인 라이프타임도 모르므로, 리스팅 10-17과 10-18에서처럼 스코프를 보고 우리가 반환하는 참조가 항상 유효한지 판단할 수 없어요. 빌림 검사기도 이것을 판단할 수 없어요. x와 y의 라이프타임이 반환값의 라이프타임과 어떻게 관련되는지 알지 못하니까요. 이 에러를 고치려면, 빌림 검사기가 분석을 수행할 수 있도록 참조들 사이의 관계를 정의하는 제네릭 라이프타임 매개변수를 추가할 거예요.
라이프타임 어노테이션 문법
라이프타임 어노테이션은 어떤 참조가 얼마나 오래 사는지를 바꾸지 않아요. 오히려 여러 참조의 라이프타임 사이의 관계를 라이프타임에는 영향 없이 설명해요. 함수가 시그니처가 제네릭 타입 매개변수를 지정하면 어떤 타입이든 받을 수 있는 것처럼, 함수는 제네릭 라이프타임 매개변수를 지정해 어떤 라이프타임이든 가진 참조를 받을 수 있어요.
라이프타임 어노테이션은 문법이 조금 특이해요. 라이프타임 매개변수의 이름은 아포스트로피(')로 시작해야 하고, 보통 제네릭 타입처럼 모두 소문자이고 아주 짧아요. 대부분의 사람들은 첫 번째 라이프타임 어노테이션에 'a라는 이름을 써요. 라이프타임 매개변수 어노테이션은 참조의 & 뒤에 두는데, 어노테이션과 참조의 타입을 공백으로 구분해요.
몇 가지 예시를 볼게요. 라이프타임 매개변수가 없는 i32에 대한 참조, 'a라는 라이프타임 매개변수를 가진 i32에 대한 참조, 그리고 'a 라이프타임도 가진 i32에 대한 가변 참조예요.
&i32 // a reference
&'a i32 // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime
라이프타임 어노테이션 하나만으로는 의미가 별로 없어요. 어노테이션들은 여러 참조의 제네릭 라이프타임 매개변수들이 서로 어떻게 관련되는지 Rust에게 알려주려는 것이기 때문이에요. longest 함수의 맥락에서 라이프타임 어노테이션들이 서로 어떻게 관련되는지 살펴볼게요.
함수 시그니처에서
함수 시그니처에서 라이프타임 어노테이션을 사용하려면, 제네릭 타입 매개변수에서 했던 것처럼 함수 이름과 매개변수 목록 사이의 꺾쇠 괄호 안에 제네릭 라이프타임 매개변수를 선언해야 해요.
우리는 시그니처가 다음 제약을 표현하길 원해요. 반환된 참조는 두 매개변수가 모두 유효한 동안 유효하다는 것이죠. 이것이 매개변수들과 반환값 사이의 라이프타임 관계예요. 라이프타임을 'a라고 이름 짓고 각 참조에 추가할 거예요. 리스팅 10-21을 볼게요.
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: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
이 코드는 컴파일되어야 하고, 리스팅 10-19의 main 함수와 함께 사용하면 우리가 원하는 결과를 내야 해요.
함수 시그니처는 이제 Rust에게 어떤 라이프타임 'a에 대해, 함수가 두 매개변수를 받는데 둘 다 적어도 'a만큼 사는 문자열 슬라이스라고 말해줘요. 함수 시그니처는 또한 함수에서 반환된 문자열 슬라이스가 적어도 'a만큼 살 것이라고 Rust에게 말해줘요. 실제로는, longest 함수가 반환하는 참조의 라이프타임이 함수 인자들이 참조하는 값들의 라이프타임 중 더 작은 것과 같다는 뜻이에요. 이것이 우리가 Rust가 이 코드를 분석할 때 사용하길 원하는 관계예요.
기억하세요, 이 함수 시그니처에서 라이프타임 매개변수를 지정할 때 우리는 전달되거나 반환되는 어떤 값의 라이프타임도 바꾸는 것이 아니에요. 오히려 빌림 검사기가 이 제약들을 지키지 않는 어떤 값도 거부하도록 지정하는 거예요. longest 함수는 x와 y가 정확히 얼마나 오래 살지 알 필요가 없으며, 이 시그니처를 만족시킬 어떤 스코프가 'a로 치환될 수 있다는 것만 알면 된다는 점을 주목하세요.
함수에서 라이프타임을 어노테이션할 때, 어노테이션은 함수 본문이 아니라 함수 시그니처에 들어가요. 라이프타임 어노테이션은 시그니처의 타입들처럼 함수의 계약의 일부가 돼요. 함수 시그니처가 라이프타임 계약을 포함하게 하면 Rust 컴파일러가 하는 분석이 더 단순해질 수 있어요. 함수가 어노테이션된 방식이나 호출되는 방식에 문제가 있으면, 컴파일러 에러가 우리 코드의 그 부분과 제약들을 더 정확히 가리킬 수 있어요. 만약 Rust 컴파일러가 라이프타임들의 관계가 우리가 의도한 것에 대해 더 많은 추론을 했다면, 컴파일러는 문제의 원인에서 여러 단계 떨어진 코드의 사용 지점만 가리키게 될지도 몰라요.
longest에 구체적인 참조를 전달하면, 'a로 치환되는 구체적인 라이프타임은 x의 스코프 중 y의 스코프와 겹치는 부분이에요. 다시 말해, 제네릭 라이프타임 'a는 x와 y의 라이프타임 중 더 작은 것과 같은 구체적인 라이프타임을 얻게 돼요. 반환된 참조를 같은 라이프타임 매개변수 'a로 어노테이션했으므로, 반환된 참조 역시 x와 y의 라이프타임 중 더 작은 것의 길이 동안 유효할 거예요.
구체적인 라이프타임이 다른 참조를 전달해 라이프타임 어노테이션이 longest 함수를 어떻게 제약하는지 살펴볼게요. 리스팅 10-22는 단순한 예시예요.
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}");
}
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
이 예시에서 string1은 외부 스코프가 끝날 때까지 유효하고, string2는 내부 스코프가 끝날 때까지 유효하며, result는 내부 스코프가 끝날 때까지 유효한 무언가를 참조해요. 이 코드를 실행하면 빌림 검사기가 승인함을 보게 돼요. 컴파일되어 The longest string is long string is long을 출력할 거예요.
다음으로, result의 참조 라이프타임이 두 인자의 더 작은 라이프타임이어야 한다는 것을 보여주는 예시를 시도해 볼게요. result 변수의 선언을 내부 스코프 밖으로 옮기되, result 변수에 값의 대입은 string2와 함께 내부 스코프 안에 두게요. 그런 다음 result를 사용하는 println!을 내부 스코프 밖, 내부 스코프가 끝난 뒤로 옮길게요. 리스팅 10-23의 코드는 컴파일되지 않아요.
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}");
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
이 코드를 컴파일하려 하면 다음 에러를 얻어요.
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
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 here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
에러는 result가 println! 문장에 대해 유효하려면 string2가 외부 스코프가 끝날 때까지 유효해야 한다는 것을 보여줘요. Rust가 이것을 아는 이유는, 우리가 함수 매개변수와 반환값의 라이프타임을 같은 라이프타임 매개변수 'a로 어노테이션했기 때문이에요.
인간으로서 우리는 이 코드를 보고 string1이 string2보다 길어서 result가 string1에 대한 참조를 담을 것임을 알 수 있어요. string1은 아직 스코프를 벗어나지 않았으므로 string1에 대한 참조는 println! 문장에서 여전히 유효할 거예요. 하지만 컴파일러는 이 경우 참조가 유효하다는 것을 볼 수 없어요. 우리는 longest 함수가 반환하는 참조의 라이프타임이 전달된 참조들의 라이프타임 중 더 작은 것과 같다고 Rust에게 말했거든요. 그래서 빌림 검사기는 리스팅 10-23의 코드가 잘못된 참조를 가질 가능성이 있는 것으로 불허해요.
longest 함수에 전달하는 참조들의 값과 라이프타임, 그리고 반환된 참조의 사용 방식을 바꿔 가며 더 많은 실험을 설계해 보세요. 컴파일하기 전에 실험이 빌림 검사기를 통과할지 아닐지 가설을 세우고, 그다음에 당신이 맞는지 확인해 보세요!
관계
라이프타임 매개변수를 어떻게 지정해야 하는지는 함수가 무엇을 하는지에 따라 달라요. 예를 들어 longest 함수의 구현을 가장 긴 문자열 슬라이스 대신 항상 첫 번째 매개변수를 반환하도록 바꾸었다면, y 매개변수에 라이프타임을 지정할 필요가 없어요. 다음 코드는 컴파일돼요.
fn main() {
let string1 = String::from("abcd");
let string2 = "efghijklmnopqrstuvwxyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
매개변수 x와 반환 타입에 라이프타임 매개변수 'a를 지정했지만, y 매개변수에는 지정하지 않았어요. 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()
}
여기서는 반환 타입에 라이프타임 매개변수 'a를 지정했는데도, 반환값 라이프타임이 매개변수들의 라이프타임과 전혀 관련이 없으므로 이 구현은 컴파일에 실패해요. 우리가 얻는 에러 메시지는 이래요.
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
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
For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
문제는 result가 longest 함수가 끝날 때 스코프를 벗어나 정리된다는 거예요. 그리고 우리는 함수에서 result에 대한 참조를 반환하려고 하죠. 댕글링 참조를 바꿀 라이프타임 매개변수를 지정할 방법은 없고, Rust는 우리가 댕글링 참조를 만들게 두지 않아요. 이 경우 최선의 해결책은 참조 대신 소유(owned) 데이터 타입을 반환해서 호출 함수가 값을 정리할 책임을 지는 것이에요.
결국 라이프타임 문법은 함수의 다양한 매개변수와 반환값의 라이프타임들을 연결하는 것에 관한 거예요. 그것들이 연결되면 Rust는 메모리 안전한 연산을 허용하고, 댕글링 포인터를 만들거나 메모리 안전성을 위반할 연산을 불허할 충분한 정보를 가지게 돼요.
구조체 정의에서
지금까지 우리가 정의한 구조체들은 모두 소유 타입을 담고 있어요. 구조체가 참조를 담도록 정의할 수도 있지만, 그 경우에는 구조체 정의의 모든 참조에 라이프타임 어노테이션을 추가해야 해요. 리스팅 10-24는 문자열 슬라이스를 담는 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의 데이터는 ImportantExcerpt 인스턴스가 만들어지기 전에 존재해요. 게다가 novel은 ImportantExcerpt가 스코프를 벗어난 뒤에야 스코프를 벗어나므로, ImportantExcerpt 인스턴스의 참조는 유효해요.
라이프타임 생략
모든 참조는 라이프타임을 가지며, 참조를 사용하는 함수나 구조체에는 라이프타임 매개변수를 지정해야 한다는 것을 배웠어요. 하지만 리스팅 4-9에 있던, 라이프타임 어노테이션 없이 컴파일된 함수가 있었는데 리스팅 10-25에 다시 나와 있어요.
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 main() {
let my_string = String::from("hello world");
// first_word works on slices of `String`s
let word = first_word(&my_string[..]);
let my_string_literal = "hello world";
// first_word works on slices of string literals
let word = first_word(&my_string_literal[..]);
// Because string literals *are* string slices already,
// this works too, without the slice syntax!
let word = first_word(my_string_literal);
}
이 함수가 라이프타임 어노테이션 없이 컴파일되는 이유는 역사적이에요. Rust의 초기 버전(1.0 이전)에서는 이 코드가 컴파일되지 않았을 거예요. 모든 참조에 명시적 라이프타임이 필요했기 때문이죠. 그 당시에는 함수 시그니처가 이렇게 작성됐을 거예요.
fn first_word<'a>(s: &'a str) -> &'a str {
Rust 코드를 많이 작성한 뒤, Rust 팀은 Rust 프로그래머들이 특정 상황에서 같은 라이프타임 어노테이션을 거듭 입력한다는 것을 발견했어요. 그 상황들은 예측 가능했고 몇 가지 결정적인 패턴을 따랐죠. 개발자들은 이 패턴들을 컴파일러 코드에 프로그래밍해서, 빌림 검사기가 그 상황들에서 라이프타임을 추론하고 명시적 어노테이션이 필요 없게 했어요.
이 Rust 역사가 관련 있는 이유는, 더 많은 결정적인 패턴이 등장해 컴파일러에 추가될 가능성이 있기 때문이에요. 미래에는 더 적은 수의 라이프타임 어노테이션이 필요할지도 몰라요.
Rust의 참조 분석에 프로그래밍된 패턴들을 **라이프타임 생략 규칙(lifetime elision rules)**이라고 불러요. 이것들은 프로그래머가 따라야 할 규칙이 아니라, 컴파일러가 고려할 특정 경우들의 집합이에요. 코드가 이 경우들에 맞으면 라이프타임을 명시적으로 쓸 필요가 없죠.
생략 규칙은 완전한 추론을 제공하지 않아요. Rust가 규칙을 적용한 후에도 참조들이 가진 라이프타임에 모호함이 남아 있으면, 컴파일러는 남은 참조들의 라이프타임이 무엇이어야 하는지 추측하지 않아요. 추측하는 대신 컴파일러는 에러를 주고, 그것을 라이프타임 어노테이션을 추가해서 해결할 수 있어요.
함수나 메서드 매개변수의 라이프타임을 **입력 라이프타임(input lifetimes)**이라고 하고, 반환값의 라이프타임을 **출력 라이프타임(output lifetimes)**이라고 해요.
컴파일러는 세 가지 규칙을 사용해 명시적 어노테이션이 없을 때 참조들의 라이프타임을 알아내요. 첫 번째 규칙은 입력 라이프타임에 적용되고, 두 번째와 세 번째 규칙은 출력 라이프타임에 적용돼요. 컴파일러가 세 규칙의 끝에 도달했는데도 라이프타임을 알아낼 수 없는 참조가 남아 있으면, 컴파일러는 에러와 함께 멈춰요. 이 규칙들은 fn 정의와 impl 블록 모두에 적용돼요.
첫 번째 규칙은 컴파일러가 참조인 각 매개변수에 라이프타임 매개변수를 하나씩 할당한다는 거예요. 다시 말해, 매개변수가 하나인 함수는 라이프타임 매개변수를 하나 가지는데 fn foo<'a>(x: &'a i32)처럼요. 매개변수가 둘인 함수는 두 개의 별도 라이프타임 매개변수를 가지는데 fn foo<'a, 'b>(x: &'a i32, y: &'b i32)처럼요. 이런 식으로 계속되죠.
두 번째 규칙은 입력 라이프타임 매개변수가 정확히 하나라면, 그 라이프타임이 모든 출력 라이프타임 매개변수에 할당된다는 거예요. fn foo<'a>(x: &'a i32) -> &'a i32처럼요.
세 번째 규칙은 입력 라이프타임 매개변수가 여러 개지만, 그중 하나가 메서드이기 때문에 &self나 &mut self라면, self의 라이프타임이 모든 출력 라이프타임 매개변수에 할당된다는 거예요. 이 세 번째 규칙 덕분에 필요한 기호가 더 적어 메서드를 읽고 쓰기가 훨씬 편해져요.
우리가 컴파일러라고 상상해 볼게요. 이 규칙들을 적용해 리스팅 10-25의 first_word 함수 시그니처의 참조들의 라이프타임을 알아내 볼 거예요. 시그니처는 참조에 연결된 라이프타임이 없는 상태로 시작해요.
fn first_word(s: &str) -> &str {
그러면 컴파일러는 첫 번째 규칙을 적용하는데, 각 매개변수가 자신만의 라이프타임을 가진다고 지정해요. 평소처럼 'a라고 부를게요. 이제 시그니처는 이래요.
fn first_word<'a>(s: &'a str) -> &str {
입력 라이프타임이 정확히 하나이므로 두 번째 규칙이 적용돼요. 두 번째 규칙은 하나의 입력 매개변수의 라이프타임이 출력 라이프타임에 할당된다고 지정하므로, 시그니처는 이제 이래요.
fn first_word<'a>(s: &'a str) -> &'a str {
이제 이 함수 시그니처의 모든 참조가 라이프타임을 가지게 됐고, 컴파일러는 프로그래머가 이 함수 시그니처의 라이프타임을 어노테이션할 필요 없이 분석을 계속할 수 있어요.
다른 예시를 볼게요. 이번에는 리스팅 10-20에서 다루기 시작했을 때 라이프타임 매개변수가 없었던 longest 함수를 사용할게요.
fn longest(x: &str, y: &str) -> &str {
첫 번째 규칙을 적용해 볼게요. 각 매개변수가 자신만의 라이프타임을 가져요. 이번에는 하나가 아니라 두 매개변수가 있으니, 두 개의 라이프타임이 있어요.
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
입력 라이프타임이 둘 이상이므로 두 번째 규칙은 적용되지 않는 것을 볼 수 있어요. 세 번째 규칙도 적용되지 않아요. longest는 함수이지 메서드가 아니므로 어떤 매개변수도 self가 아니거든요. 세 규칙을 모두 거쳤는데도 우리는 여전히 반환 타입의 라이프타임이 무엇인지 알아내지 못했어요. 그렇기 때문에 리스팅 10-20의 코드를 컴파일하려 할 때 에러를 얻었던 거예요. 컴파일러가 라이프타임 생략 규칙을 모두 거쳤지만 여전히 시그니처의 참조들의 라이프타임을 모두 알아낼 수 없었으니까요.
세 번째 규칙이 실제로는 메서드 시그니처에서만 적용되므로, 그 맥락에서 라이프타임을 살펴보면서 왜 세 번째 규칙 덕분에 메서드 시그니처에서 라이프타임을 자주 어노테이션하지 않아도 되는지 볼게요.
메서드 정의에서
라이프타임이 있는 구조체에 메서드를 구현할 때는 리스팅 10-11에서 본 제네릭 타입 매개변수의 문법과 같은 문법을 사용해요. 라이프타임 매개변수를 어디서 선언하고 사용하는지는, 그것들이 구조체 필드와 관련되는지 아니면 메서드 매개변수와 반환값과 관련되는지에 따라 달라져요.
구조체 필드의 라이프타임 이름은 항상 impl 키워드 뒤에 선언한 다음 구조체 이름 뒤에 사용해야 해요. 그 라이프타임들이 구조체 타입의 일부이기 때문이에요.
impl 블록 안의 메서드 시그니처에서는 참조가 구조체 필드의 참조 라이프타임에 묶일 수도 있고, 독립적일 수도 있어요. 게다가 라이프타임 생략 규칙 덕분에 메서드 시그니처에서 라이프타임 어노테이션이 필요 없게 되는 경우가 많아요. 리스팅 10-24에서 정의한 ImportantExcerpt 구조체를 사용하는 몇 가지 예시를 볼게요.
먼저, self에 대한 참조만을 매개변수로 하고 아무것도 참조하지 않는 i32를 반환값으로 가지는 level이라는 메서드를 사용할 거예요.
struct ImportantExcerpt<'a> {
part: &'a str,
}
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {announcement}");
self.part
}
}
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,
};
}
impl 뒤의 라이프타임 매개변수 선언과 타입 이름 뒤의 사용은 필수지만, 첫 번째 생략 규칙 덕분에 self에 대한 참조의 라이프타임을 어노테이션할 필요는 없어요.
세 번째 라이프타임 생략 규칙이 적용되는 예시를 볼게요.
struct ImportantExcerpt<'a> {
part: &'a str,
}
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {announcement}");
self.part
}
}
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,
};
}
입력 라이프타임이 두 개 있으므로 Rust는 첫 번째 생략 규칙을 적용해 &self와 announcement 둘 다 자신만의 라이프타임을 줘요. 그런 다음 매개변수 중 하나가 &self이므로 반환 타입이 &self의 라이프타임을 갖게 되고, 모든 라이프타임이 처리되게 돼요.
정적 라이프타임
다뤄야 할 특별한 라이프타임 하나는 'static인데, 이것은 해당 참조가 프로그램의 전체 기간 동안 살 수 있음을 나타내요. 모든 문자열 리터럴은 'static 라이프타임을 가지며, 다음과 같이 어노테이션할 수 있어요.
#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}
이 문자열의 텍스트는 프로그램의 바이너리에 직접 저장되므로 항상 사용 가능해요. 그래서 모든 문자열 리터럴의 라이프타임은 'static이에요.
에러 메시지에서 'static 라이프타임을 사용하라는 제안을 볼 수도 있어요. 하지만 참조의 라이프타임으로 'static을 지정하기 전에, 가지고 있는 참조가 실제로 프로그램의 전체 라이프타임 동안 사는지, 그리고 그것이 되길 원하는지 생각해 보세요. 대부분의 경우 'static 라이프타임을 제안하는 에러 메시지는 댕글링 참조를 만들거나 사용 가능한 라이프타임들이 일치하지 않는 데서 비롯돼요. 그런 경우 해결책은 'static 라이프타임을 지정하는 것이 아니라, 그 문제들을 고치는 거예요.
제네릭 타입 매개변수, 트레이트 바운드, 라이프타임
제네릭 타입 매개변수, 트레이트 바운드, 라이프타임을 한 함수에 모두 지정하는 문법을 간단히 살펴볼게요.
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest_with_an_announcement(
string1.as_str(),
string2,
"Today is someone's birthday!",
);
println!("The longest string is {result}");
}
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 }
}
이것은 두 문자열 슬라이스 중 더 긴 것을 반환하는 리스팅 10-21의 longest 함수예요. 하지만 이제는 where 절이 지정한 대로 Display 트레이트를 구현하는 어떤 타입이든 채워질 수 있는 제네릭 타입 T의 ann이라는 추가 매개변수가 있어요. 이 추가 매개변수는 {}를 사용해 출력될 것이므로 Display 트레이트 바운드가 필요해요. 라이프타임은 타입의 제네릭이므로, 라이프타임 매개변수 'a와 제네릭 타입 매개변수 T의 선언은 함수 이름 뒤의 꺾쇠 괄호 안 같은 목록에 들어가요.
정리
이 장에서 많은 것을 다뤘어요! 이제 제네릭 타입 매개변수, 트레이트와 트레이트 바운드, 제네릭 라이프타임 매개변수를 알게 됐으니, 많은 다양한 상황에서 동작하는 반복 없는 코드를 작성할 준비가 됐어요. 제네릭 타입 매개변수는 코드를 다른 타입에 적용할 수 있게 해 줘요. 트레이트와 트레이트 바운드는 타입이 제네릭이라 해도 코드가 필요로 하는 동작을 가질 것임을 보장해 줘요. 라이프타임 어노테이션을 사용해 이 유연한 코드에 댕글링 참조가 없도록 하는 방법을 배웠고요. 그리고 이 모든 분석은 컴파일 타임에 일어나므로 런타임 성능에 영향을 주지 않아요!
믿기 어렵겠지만, 이 장에서 논의한 주제들에는 더 배울 것이 많아요. 18장은 트레이트 객체(trait object)를 다루는데, 이것은 트레이트를 사용하는 또 다른 방법이에요. 라이프타임 어노테이션을 포함하는 더 복잡한 시나리오도 있는데, 아주 고급 시나리오에서만 필요할 거예요. 그런 경우에는 Rust 참조(Rust Reference)를 읽어 보세요. 하지만 다음으로는 Rust에서 테스트를 작성하는 방법을 배워서 코드가 제대로 동작하는지 확인할 수 있게 될 거예요.