소개
생명주기를 사용하여 참조 유효성 검사에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.
이 랩에서는 생명주기 (lifetimes) 에 대해 논의하고, 생명주기가 필요한 기간 동안 참조가 유효하도록 보장하는 방법을 살펴봅니다. 생명주기가 익숙하지 않게 느껴질 수 있지만, 생명주기 구문을 접할 수 있는 일반적인 방법을 다루어 개념에 익숙해지도록 돕겠습니다.
생명주기를 사용한 참조 유효성 검사
생명주기 (lifetimes) 는 우리가 이미 사용해 온 또 다른 종류의 제네릭 (generic) 입니다. 생명주기는 타입 (type) 이 원하는 동작을 하도록 보장하는 대신, 참조 (reference) 가 필요한 기간 동안 유효하도록 보장합니다.
"참조와 빌림 (References and Borrowing)"에서 논의하지 않은 한 가지 세부 사항은 Rust 의 모든 참조에는 해당 참조가 유효한 범위인 생명주기가 있다는 것입니다. 대부분의 경우, 생명주기는 암시적 (implicit) 이며 추론 (infer) 됩니다. 이는 대부분의 경우 타입이 추론되는 것과 같습니다. 여러 타입이 가능한 경우에만 타입을 주석 처리해야 합니다. 이와 유사하게, 참조의 생명주기가 몇 가지 다른 방식으로 관련될 수 있는 경우 생명주기를 주석 처리해야 합니다. Rust 는 런타임에 사용되는 실제 참조가 확실히 유효하도록 하기 위해 제네릭 생명주기 매개변수를 사용하여 관계에 주석을 달도록 요구합니다.
생명주기 주석 처리는 다른 대부분의 프로그래밍 언어에는 없는 개념이므로 익숙하지 않게 느껴질 것입니다. 이 장에서 생명주기를 완전히 다루지는 않겠지만, 개념에 익숙해질 수 있도록 생명주기 구문을 접할 수 있는 일반적인 방법을 논의하겠습니다.
생명주기를 사용하여 댕글링 참조 방지
생명주기의 주요 목표는 프로그램이 참조하려는 데이터가 아닌 다른 데이터를 참조하게 만드는 *댕글링 참조 (dangling references)*를 방지하는 것입니다. Listing 10-16 의 프로그램을 생각해 봅시다. 이 프로그램은 외부 범위와 내부 범위를 가지고 있습니다.
fn main() {
1 let r;
{
2 let x = 5;
3 r = &x;
4 }
5 println!("r: {r}");
}
Listing 10-16: 값이 범위를 벗어난 참조를 사용하려는 시도
참고: Listing 10-16, 10-17, 및 10-23 의 예제는 초기값을 지정하지 않고 변수를 선언하므로 변수 이름이 외부 범위에 존재합니다. 언뜻 보면 이것은 Rust 가 null 값을 갖지 않는다는 것과 충돌하는 것처럼 보일 수 있습니다. 그러나 값을 지정하기 전에 변수를 사용하려고 하면 컴파일 시간 오류가 발생하며, 이는 Rust 가 실제로 null 값을 허용하지 않음을 보여줍니다.
외부 범위는 초기값이 없는 r이라는 변수를 선언하고 [1], 내부 범위는 초기값이 5인 x라는 변수를 선언합니다 [2]. 내부 범위 내에서 r의 값을 x에 대한 참조로 설정하려고 시도합니다 [3]. 그런 다음 내부 범위가 종료되고 [4], r의 값을 출력하려고 시도합니다 [5]. 이 코드는 r이 참조하는 값이 사용하려는 시점 전에 범위를 벗어나기 때문에 컴파일되지 않습니다. 다음은 오류 메시지입니다.
error[E0597]: `x` does not live long enough
--> src/main.rs:6:13
|
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은 외부 범위에 대해 여전히 유효합니다. 해당 범위가 더 크기 때문에 "더 오래 산다"고 말합니다. Rust 가 이 코드가 작동하도록 허용했다면, r은 x가 범위를 벗어났을 때 할당 해제된 메모리를 참조하게 될 것이고, r으로 하려는 모든 작업은 제대로 작동하지 않을 것입니다. 그렇다면 Rust 는 이 코드가 유효하지 않다는 것을 어떻게 결정할까요? 차용 검사기 (borrow checker) 를 사용합니다.
차용 검사기 (Borrow Checker)
Rust 컴파일러에는 모든 차용 (borrow) 이 유효한지 확인하기 위해 범위를 비교하는 차용 검사기가 있습니다. Listing 10-17 은 Listing 10-16 과 동일한 코드를 보여주지만 변수의 생명주기를 주석 처리했습니다.
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
Listing 10-17: 각각 'a와 'b로 명명된 r과 x의 생명주기 주석
여기서 r의 생명주기를 'a로, x의 생명주기를 'b로 주석 처리했습니다. 보시다시피 내부 'b 블록은 외부 'a 생명주기 블록보다 훨씬 작습니다. 컴파일 시간에 Rust 는 두 생명주기의 크기를 비교하고 r의 생명주기는 'a이지만 'b의 생명주기를 가진 메모리를 참조한다는 것을 확인합니다. 프로그램은 'b가 'a보다 짧기 때문에 거부됩니다. 즉, 참조 대상이 참조만큼 오래 살지 못합니다.
Listing 10-18 은 댕글링 참조가 없고 오류 없이 컴파일되는 코드를 수정합니다.
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {r}"); // | |
// --+ |
} // ----------+
Listing 10-18: 데이터가 참조보다 더 긴 생명주기를 가지므로 유효한 참조
여기서 x는 'b 생명주기를 가지며, 이 경우 'a보다 큽니다. 즉, Rust 는 r의 참조가 x가 유효한 동안 항상 유효하다는 것을 알고 있으므로 r이 x를 참조할 수 있습니다.
이제 참조의 생명주기가 어디에 있는지, 그리고 Rust 가 참조가 항상 유효하도록 생명주기를 분석하는 방법을 알았으므로 함수의 컨텍스트에서 매개변수 및 반환 값의 제네릭 생명주기를 살펴보겠습니다.
함수에서 제네릭 생명주기
두 문자열 슬라이스 중 더 긴 슬라이스를 반환하는 함수를 작성해 보겠습니다. 이 함수는 두 개의 문자열 슬라이스를 받아 단일 문자열 슬라이스를 반환합니다. longest 함수를 구현한 후, Listing 10-19 의 코드는 The longest string is abcd를 출력해야 합니다.
Filename: src/main.rs
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
Listing 10-19: 두 문자열 슬라이스 중 더 긴 슬라이스를 찾기 위해 longest 함수를 호출하는 main 함수
longest 함수가 매개변수의 소유권을 가져가지 않도록 하기 위해 문자열 대신 참조인 문자열 슬라이스를 함수가 사용하도록 하려고 합니다. Listing 10-19 에서 사용하는 매개변수가 원하는 매개변수인 이유에 대한 자세한 내용은 "매개변수로서의 문자열 슬라이스"를 참조하십시오.
Listing 10-20 과 같이 longest 함수를 구현하려고 하면 컴파일되지 않습니다.
Filename: src/main.rs
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
Listing 10-20: 두 문자열 슬라이스 중 더 긴 슬라이스를 반환하지만 아직 컴파일되지 않는 longest 함수의 구현
대신, 생명주기에 대해 이야기하는 다음 오류가 발생합니다.
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를 참조하는지 Rust 가 알 수 없기 때문에 반환 유형에 제네릭 생명주기 매개변수가 필요함을 나타냅니다. 실제로, 이 함수의 본문에서 if 블록은 x에 대한 참조를 반환하고 else 블록은 y에 대한 참조를 반환하므로 우리도 알 수 없습니다!
이 함수를 정의할 때, 이 함수에 전달될 구체적인 값을 알 수 없으므로 if 케이스 또는 else 케이스가 실행될지 알 수 없습니다. 또한 전달될 참조의 구체적인 생명주기를 알 수 없으므로 Listing 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로 지정한 다음, Listing 10-21 과 같이 각 참조에 추가합니다.
Filename: src/main.rs
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
Listing 10-21: 시그니처의 모든 참조가 동일한 생명주기 'a를 가져야 함을 지정하는 longest 함수 정의
이 코드는 Listing 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 함수를 어떻게 제한하는지 살펴보겠습니다. Listing 10-22 는 간단한 예입니다.
Filename: src/main.rs
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}");
}
}
Listing 10-22: 서로 다른 구체적인 생명주기를 가진 String 값에 대한 참조와 함께 longest 함수 사용
이 예에서 string1은 외부 범위의 끝까지 유효하고, string2는 내부 범위의 끝까지 유효하며, result는 내부 범위의 끝까지 유효한 것을 참조합니다. 이 코드를 실행하면 차용 검사기가 승인하는 것을 볼 수 있습니다. 컴파일되어 The longest string is long string is long을 출력합니다.
다음으로, result의 참조의 생명주기가 두 인수의 더 작은 생명주기여야 함을 보여주는 예제를 시도해 보겠습니다. result 변수의 선언을 내부 범위 외부로 이동하지만, 값을 result 변수에 할당하는 것은 string2와 함께 범위 내에 둡니다. 그런 다음 result를 사용하는 println!을 내부 범위 외부, 즉 내부 범위가 종료된 후에 이동합니다. Listing 10-23 의 코드는 컴파일되지 않습니다.
Filename: src/main.rs
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}");
}
Listing 10-23: string2가 범위를 벗어난 후 result를 사용하려고 시도
이 코드를 컴파일하려고 하면 다음과 같은 오류가 발생합니다.
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
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
오류는 result가 println! 문에 대해 유효하려면 string2가 외부 범위의 끝까지 유효해야 함을 보여줍니다. Rust 는 동일한 생명주기 매개변수 'a를 사용하여 함수 매개변수와 반환 값의 생명주기를 어노테이션했기 때문에 이를 알고 있습니다.
사람으로서, 우리는 이 코드를 보고 string1이 string2보다 길다는 것을 알 수 있으며, 따라서 result는 string1에 대한 참조를 포함합니다. string1이 아직 범위를 벗어나지 않았으므로, string1에 대한 참조는 여전히 println! 문에 대해 유효합니다. 그러나 컴파일러는 이 경우 참조가 유효하다는 것을 알 수 없습니다. 우리는 Rust 에게 longest 함수에 의해 반환된 참조의 생명주기가 전달된 참조의 생명주기 중 더 작은 것과 같다고 말했습니다. 따라서 차용 검사기는 Listing 10-23 의 코드가 잠재적으로 유효하지 않은 참조를 가질 수 있다고 거부합니다.
longest 함수에 전달된 참조의 값과 생명주기를 변경하고 반환된 참조가 사용되는 방식을 변경하는 더 많은 실험을 설계해 보십시오. 컴파일하기 전에 실험이 차용 검사기를 통과할지 여부에 대한 가설을 세우고, 그런 다음 맞는지 확인하십시오!
생명주기 관점에서 생각하기
생명주기 매개변수를 지정해야 하는 방식은 함수가 수행하는 작업에 따라 다릅니다. 예를 들어, longest 함수의 구현을 가장 긴 문자열 슬라이스 대신 항상 첫 번째 매개변수를 반환하도록 변경하면, y 매개변수에 생명주기를 지정할 필요가 없습니다. 다음 코드는 컴파일됩니다.
Filename: src/main.rs
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
매개변수 x와 반환 타입에 대해 생명주기 매개변수 'a를 지정했지만, 매개변수 y에 대해서는 지정하지 않았습니다. 왜냐하면 y의 생명주기는 x 또는 반환 값의 생명주기와 아무런 관계가 없기 때문입니다.
함수에서 참조를 반환할 때, 반환 타입의 생명주기 매개변수는 매개변수 중 하나의 생명주기 매개변수와 일치해야 합니다. 반환된 참조가 매개변수 중 하나를 참조하지 않는 경우, 이 함수 내에서 생성된 값을 참조해야 합니다. 그러나 이 값은 함수가 끝날 때 범위를 벗어나기 때문에 이는 댕글링 참조 (dangling reference) 가 됩니다. 컴파일되지 않는 longest 함수의 이 시도된 구현을 고려하십시오.
Filename: src/main.rs
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}
여기서는 반환 타입에 대해 생명주기 매개변수 'a를 지정했지만, 이 구현은 반환 값의 생명주기가 매개변수의 생명주기와 전혀 관련이 없기 때문에 컴파일에 실패합니다. 다음은 우리가 얻는 오류 메시지입니다.
error[E0515]: cannot return reference to local variable `result`
--> src/main.rs:11:5
|
11 | result.as_str()
| ^^^^^^^^^^^^^^^ returns a reference to data owned by the
current function
문제는 result가 범위를 벗어나고 longest 함수의 끝에서 정리된다는 것입니다. 또한 함수에서 result에 대한 참조를 반환하려고 합니다. 댕글링 참조를 변경할 수 있는 생명주기 매개변수를 지정할 방법이 없으며, Rust 는 댕글링 참조를 생성하도록 허용하지 않습니다. 이 경우, 가장 좋은 해결책은 참조 대신 소유된 데이터 타입을 반환하여 호출 함수가 값을 정리하도록 하는 것입니다.
궁극적으로, 생명주기 구문은 함수의 다양한 매개변수와 반환 값의 생명주기를 연결하는 것입니다. 일단 연결되면, Rust 는 메모리 안전한 작업을 허용하고 댕글링 포인터를 생성하거나 메모리 안전성을 위반하는 작업을 허용하지 않도록 충분한 정보를 갖게 됩니다.
구조체 정의의 생명주기 어노테이션
지금까지 우리가 정의한 구조체는 모두 소유된 타입을 가지고 있습니다. 참조를 저장하는 구조체를 정의할 수 있지만, 이 경우 구조체 정의의 모든 참조에 생명주기 어노테이션을 추가해야 합니다. Listing 10-24 는 문자열 슬라이스를 저장하는 ImportantExcerpt라는 구조체를 가지고 있습니다.
Filename: src/main.rs
1 struct ImportantExcerpt<'a> {
2 part: &'a str,
}
fn main() {
3 let novel = String::from(
"Call me Ishmael. Some years ago..."
);
4 let first_sentence = novel
.split('.')
.next()
.expect("Could not find a '.'");
5 let i = ImportantExcerpt {
part: first_sentence,
};
}
Listing 10-24: 생명주기 어노테이션이 필요한 참조를 저장하는 구조체
이 구조체는 문자열 슬라이스를 저장하는 단일 필드 part를 가지고 있으며, 이는 참조입니다 [2]. 제네릭 데이터 타입과 마찬가지로, 구조체 이름 뒤에 꺾쇠 괄호 안에 제네릭 생명주기 매개변수의 이름을 선언하여 구조체 정의 본문에서 생명주기 매개변수를 사용할 수 있습니다 [1]. 이 어노테이션은 ImportantExcerpt의 인스턴스가 part 필드에 저장된 참조보다 오래 지속될 수 없음을 의미합니다.
여기 main 함수는 변수 novel [3]이 소유한 String [4]의 첫 번째 문장에 대한 참조를 저장하는 ImportantExcerpt 구조체의 인스턴스를 생성합니다 [5]. novel의 데이터는 ImportantExcerpt 인스턴스가 생성되기 전에 존재합니다. 또한, novel은 ImportantExcerpt가 범위를 벗어날 때까지 범위를 벗어나지 않으므로, ImportantExcerpt 인스턴스의 참조는 유효합니다.
생명주기 생략
모든 참조에는 생명주기가 있으며, 참조를 사용하는 함수 또는 구조체에 생명주기 매개변수를 지정해야 한다는 것을 배웠습니다. 그러나 Listing 4-9 에 있는 함수는 Listing 10-25 에 다시 표시되었으며, 생명주기 어노테이션 없이 컴파일되었습니다.
Filename: src/lib.rs
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[..]
}
Listing 10-25: 매개변수와 반환 타입이 참조임에도 불구하고 생명주기 어노테이션 없이 컴파일된 Listing 4-9 에서 정의한 함수
이 함수가 생명주기 어노테이션 없이 컴파일되는 이유는 역사적인 이유 때문입니다. 초기 버전 (1.0 이전) 의 Rust 에서는 모든 참조에 명시적인 생명주기가 필요했기 때문에 이 코드는 컴파일되지 않았을 것입니다. 당시 함수 시그니처는 다음과 같이 작성되었을 것입니다.
fn first_word<'a>(s: &'a str) -> &'a str {
많은 Rust 코드를 작성한 후, Rust 팀은 Rust 프로그래머가 특정 상황에서 동일한 생명주기 어노테이션을 반복해서 입력하고 있다는 것을 발견했습니다. 이러한 상황은 예측 가능했으며 몇 가지 결정론적 패턴을 따랐습니다. 개발자는 이러한 패턴을 컴파일러 코드에 프로그래밍하여 차용 검사기 (borrow checker) 가 이러한 상황에서 생명주기를 추론하고 명시적인 어노테이션이 필요하지 않도록 했습니다.
이 Rust 역사는 더 많은 결정론적 패턴이 나타나 컴파일러에 추가될 수 있기 때문에 관련이 있습니다. 미래에는 더 적은 생명주기 어노테이션이 필요할 수 있습니다.
Rust 의 참조 분석에 프로그래밍된 패턴을 생명주기 생략 규칙이라고 합니다. 이것은 프로그래머가 따라야 하는 규칙이 아니라, 컴파일러가 고려할 특정 사례 집합이며, 코드가 이러한 사례에 맞는 경우 생명주기를 명시적으로 작성할 필요가 없습니다.
생략 규칙은 완전한 추론을 제공하지 않습니다. Rust 가 규칙을 결정론적으로 적용하지만 참조가 어떤 생명주기를 갖는지에 대한 모호성이 여전히 있는 경우, 컴파일러는 나머지 참조의 생명주기가 무엇인지 추측하지 않습니다. 추측하는 대신, 컴파일러는 생명주기 어노테이션을 추가하여 해결할 수 있는 오류를 제공합니다.
함수 또는 메서드 매개변수의 생명주기는 입력 생명주기라고 하며, 반환 값의 생명주기는 출력 생명주기라고 합니다.
컴파일러는 명시적인 어노테이션이 없을 때 참조의 생명주기를 파악하기 위해 세 가지 규칙을 사용합니다. 첫 번째 규칙은 입력 생명주기에 적용되고, 두 번째 및 세 번째 규칙은 출력 생명주기에 적용됩니다. 컴파일러가 세 가지 규칙의 끝에 도달했지만 여전히 생명주기를 파악할 수 없는 참조가 있는 경우, 컴파일러는 오류와 함께 중지됩니다. 이러한 규칙은 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의 생명주기가 모든 출력 생명주기 매개변수에 할당된다는 것입니다. 이 세 번째 규칙은 메서드를 훨씬 더 읽고 쓰기 쉽게 만듭니다. 왜냐하면 더 적은 기호가 필요하기 때문입니다.
컴파일러라고 가정해 봅시다. Listing 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 {
이제 이 함수 시그니처의 모든 참조에는 생명주기가 있으며, 컴파일러는 프로그래머가 이 함수 시그니처에서 생명주기를 주석 처리할 필요 없이 분석을 계속할 수 있습니다.
다른 예제를 살펴보겠습니다. 이번에는 Listing 10-20 에서 작업을 시작했을 때 생명주기 매개변수가 없었던 longest 함수를 사용합니다.
fn longest(x: &str, y: &str) -> &str {
첫 번째 규칙을 적용해 봅시다: 각 매개변수는 자체 생명주기를 갖습니다. 이번에는 하나가 아닌 두 개의 매개변수가 있으므로, 두 개의 생명주기가 있습니다.
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
두 번째 규칙은 둘 이상의 입력 생명주기가 있으므로 적용되지 않는 것을 볼 수 있습니다. 세 번째 규칙도 적용되지 않습니다. 왜냐하면 longest는 메서드가 아닌 함수이므로, 매개변수 중 어느 것도 self가 아니기 때문입니다. 세 가지 규칙을 모두 적용한 후에도, 반환 타입의 생명주기가 무엇인지 파악하지 못했습니다. 이것이 Listing 10-20 에서 코드를 컴파일하려고 할 때 오류가 발생한 이유입니다. 컴파일러는 생명주기 생략 규칙을 적용했지만, 여전히 시그니처의 모든 참조의 생명주기를 파악할 수 없었습니다.
세 번째 규칙은 실제로 메서드 시그니처에만 적용되므로, 다음으로 해당 컨텍스트에서 생명주기를 살펴보고 세 번째 규칙이 메서드 시그니처에서 생명주기를 자주 주석 처리할 필요가 없는 이유를 살펴보겠습니다.
메서드 정의의 생명주기 어노테이션
생명주기를 가진 구조체에 메서드를 구현할 때, Listing 10-11 에 표시된 제네릭 타입 매개변수와 동일한 구문을 사용합니다. 생명주기 매개변수를 선언하고 사용하는 위치는 구조체 필드와 관련이 있는지 또는 메서드 매개변수 및 반환 값과 관련이 있는지에 따라 달라집니다.
구조체 필드의 생명주기 이름은 항상 impl 키워드 뒤에 선언된 다음, 구조체의 이름 뒤에 사용되어야 합니다. 왜냐하면 해당 생명주기는 구조체의 타입의 일부이기 때문입니다.
impl 블록 내부의 메서드 시그니처에서 참조는 구조체 필드의 참조의 생명주기에 묶일 수도 있고, 독립적일 수도 있습니다. 또한, 생명주기 생략 규칙은 종종 메서드 시그니처에서 생명주기 어노테이션이 필요하지 않도록 만듭니다. Listing 10-24 에서 정의한 ImportantExcerpt라는 구조체를 사용하여 몇 가지 예제를 살펴보겠습니다.
먼저, 유일한 매개변수가 self에 대한 참조이고 반환 값이 i32인 level이라는 메서드를 사용합니다. i32는 어떤 것에 대한 참조가 아닙니다.
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
}
}
두 개의 입력 생명주기가 있으므로, Rust 는 첫 번째 생명주기 생략 규칙을 적용하고 &self와 announcement 모두에 자체 생명주기를 부여합니다. 그런 다음, 매개변수 중 하나가 &self이므로 반환 타입은 &self의 생명주기를 갖게 되며, 모든 생명주기가 고려되었습니다.
Static 생명주기
논의해야 할 특별한 생명주기 중 하나는 'static입니다. 이는 영향을 받는 참조가 프로그램의 전체 기간 동안 존재할 수 있음을 나타냅니다. 모든 문자열 리터럴은 'static 생명주기를 가지며, 다음과 같이 주석 처리할 수 있습니다.
let s: &'static str = "I have a static lifetime.";
이 문자열의 텍스트는 프로그램의 바이너리에 직접 저장되며, 항상 사용 가능합니다. 따라서 모든 문자열 리터럴의 생명주기는 'static입니다.
오류 메시지에서 'static 생명주기를 사용하라는 제안을 볼 수 있습니다. 그러나 참조에 대한 생명주기로 'static을 지정하기 전에, 실제로 가지고 있는 참조가 프로그램의 전체 생명주기 동안 존재하는지, 그리고 그렇게 하기를 원하는지 생각해 보십시오. 대부분의 경우, 'static 생명주기를 제안하는 오류 메시지는 댕글링 참조 (dangling reference) 를 생성하려는 시도 또는 사용 가능한 생명주기의 불일치로 인해 발생합니다. 이러한 경우, 해결책은 이러한 문제를 해결하는 것이지, 'static 생명주기를 지정하는 것이 아닙니다.
제네릭 타입 매개변수, 트레이트 바운드, 그리고 생명주기를 함께 사용하기
제네릭 타입 매개변수, 트레이트 바운드, 그리고 생명주기를 모두 하나의 함수에서 지정하는 구문을 간략하게 살펴보겠습니다!
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
}
}
이것은 Listing 10-21 의 longest 함수로, 두 개의 문자열 슬라이스 중 더 긴 것을 반환합니다. 하지만 이제 T라는 제네릭 타입의 ann이라는 추가 매개변수가 있으며, where 절에 지정된 대로 Display 트레이트를 구현하는 모든 타입으로 채울 수 있습니다. 이 추가 매개변수는 {}를 사용하여 출력되므로, Display 트레이트 바운드가 필요합니다. 생명주기는 일종의 제네릭이므로, 생명주기 매개변수 'a와 제네릭 타입 매개변수 T의 선언은 함수 이름 뒤의 꺾쇠 괄호 안의 동일한 목록에 들어갑니다.
요약
축하합니다! 생명주기를 사용하여 참조를 검증하는 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 실력을 향상시킬 수 있습니다.