Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

라이프타임으로 참조의 유효성 검증하기

라이프타임은 우리가 이미 써 오고 있던 또 다른 종류의 제네릭입니다. 타입이 원하는 동작을 갖는지 보장하는 대신, 라이프타임은 참조가 필요한 만큼 오래 유효한지를 보장합니다.

4장의 “참조와 대여” 절에서는 설명하지 않았던 세부가 하나 있는데, 러스트의 모든 참조는 저마다의 라이프타임을 가진다는 점입니다. 라이프타임은 그 참조가 유효한 스코프를 뜻합니다. 대부분의 경우 라이프타임은 암묵적이고 추론됩니다. 타입이 대개 추론되는 것과 비슷합니다. 여러 타입이 가능할 때만 우리가 타입을 주석으로 적어 주어야 합니다. 마찬가지로 참조의 라이프타임이 여러 방식으로 관련될 수 있는 경우에만 라이프타임을 주석으로 명시해야 합니다. 러스트는 런타임에 실제로 사용되는 참조들이 확실히 유효하도록, 제네릭 라이프타임 매개변수로 참조들 사이 관계를 적어 주길 요구합니다.

라이프타임 주석은 대부분의 다른 프로그래밍 언어에는 없는 개념이므로 낯설게 느껴질 수 있습니다. 이 장에서 라이프타임 전체를 완전히 다루지는 않겠지만, 라이프타임 문법을 만날 수 있는 흔한 상황들을 설명해 개념에 익숙해지게 하겠습니다.

댕글링 참조

라이프타임의 주된 목적은 댕글링 참조를 막는 것입니다. 만약 허용된다면, 프로그램은 원래 의도한 데이터가 아닌 다른 데이터를 참조하게 될 수도 있습니다. 목록 10-16의 프로그램은 바깥 스코프와 안쪽 스코프를 함께 가지고 있습니다.

fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {r}");
}
Listing 10-16: 이미 스코프를 벗어난 값을 참조하는 참조를 사용하려는 시도

Note: 목록 10-16, 10-17, 10-23의 예제에서는 변수에 초기값을 주지 않고 선언합니다. 그래서 변수 이름은 바깥 스코프 안에 존재합니다. 언뜻 보면 이는 러스트에 null 값이 없다는 사실과 충돌하는 것처럼 보일 수도 있습니다. 하지만 값을 주기 전에 그 변수를 사용하려고 하면 컴파일 시점 오류가 발생합니다. 즉, 러스트는 실제로 null 값을 허용하지 않는다는 점을 보여 줍니다.

바깥 스코프에는 초기값이 없는 r 이라는 변수가 선언되어 있고, 안쪽 스코프에는 초기값 5 를 가진 x 가 선언되어 있습니다. 안쪽 스코프에서 우리는 r 의 값을 x 에 대한 참조로 설정하려고 합니다. 그 뒤 안쪽 스코프는 끝나고, 바깥 스코프에서 r 의 값을 출력하려 합니다. 이 코드는 컴파일되지 않습니다. 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 가 “충분히 오래 살지 않는다”고 말합니다. 이유는 안쪽 스코프가 7번째 줄에서 끝날 때 x 도 스코프를 벗어나기 때문입니다. 하지만 r 은 바깥 스코프에 있어 더 오래 유효합니다. 그래서 우리는 r 이 “더 오래 산다”고 말합니다. 만약 러스트가 이 코드를 허용했다면, rx 가 스코프를 벗어날 때 이미 해제된 메모리를 가리키게 되었을 것이고, 그 뒤 r 에 대해 무엇을 하든 올바르게 동작하지 않았을 것입니다. 그렇다면 러스트는 어떻게 이 코드가 잘못되었다는 것을 알까요? 대여 검사기를 사용합니다.

대여 검사기

러스트 컴파일러에는 모든 대여가 유효한지 확인하기 위해 스코프를 비교하는 대여 검사기(borrow checker) 가 있습니다. 목록 10-17은 목록 10-16과 같은 코드에, 변수 라이프타임을 주석으로 표시한 버전입니다.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+
Listing 10-17: 각각 'a, 'b 로 이름 붙인 rx 의 라이프타임 주석

여기서는 r 의 라이프타임을 'a, x 의 라이프타임을 'b 라고 표시했습니다. 보시다시피 안쪽 'b 블록은 바깥쪽 'a 라이프타임 블록보다 훨씬 작습니다. 컴파일 시점에 러스트는 두 라이프타임 크기를 비교하고, r'a 라이프타임을 가지지만 가리키는 메모리는 'b 라이프타임을 가진다는 사실을 확인합니다. 그래서 프로그램을 거부합니다. 'b'a 보다 짧기 때문입니다. 참조 대상이 참조보다 오래 살지 못하는 것입니다.

목록 10-18은 댕글링 참조가 없도록 코드를 고친 버전이며, 오류 없이 컴파일됩니다.

fn main() {
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {r}");   //   |       |
                          // --+       |
}                         // ----------+
Listing 10-18: 참조보다 데이터의 라이프타임이 더 길기 때문에 유효한 참조

여기서는 x 의 라이프타임 'b'a 보다 더 길기 때문에, rx 를 참조해도 문제가 없습니다. 러스트는 x 가 유효한 동안 r 안의 참조도 항상 유효하다는 사실을 알고 있습니다.

이제 참조의 라이프타임이 무엇이고, 러스트가 라이프타임을 분석해 참조가 언제나 유효하도록 어떻게 보장하는지 알게 되었으니, 함수 매개변수와 반환값에서 제네릭 라이프타임을 어떻게 사용하는지 살펴보겠습니다.

함수에서의 제네릭 라이프타임

문자열 슬라이스 두 개 중 더 긴 쪽을 반환하는 함수를 작성해 보겠습니다. 이 함수는 두 개의 문자열 슬라이스를 받아 하나의 문자열 슬라이스를 반환할 것입니다. longest 함수를 구현하고 나면, 목록 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 가 인수의 소유권을 가져가길 원하지 않기 때문입니다. 왜 이런 매개변수를 원하는지에 대해서는 4장의 [“문자열 슬라이스를 매개변수로 받기”] string-slices-as-parameters 절을 떠올려 보세요.

만약 목록 10-20처럼 longest 함수를 구현하려 하면, 이 코드는 컴파일되지 않습니다.

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}");
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-20: 두 문자열 슬라이스 중 더 긴 쪽을 반환하려 하지만 아직 컴파일되지 않는 longest 구현

대신 우리는 라이프타임에 대해 이야기하는 다음과 같은 오류를 얻게 됩니다.

$ 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

도움말은 반환 타입에 제네릭 라이프타임 매개변수가 필요하다고 알려 줍니다. 러스트는 반환되는 참조가 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

라이프타임 주석 하나만 떼어 놓고 보면 큰 의미는 없습니다. 주석의 목적은 여러 참조의 제네릭 라이프타임 매개변수들 사이 관계를 러스트에게 알려 주는 데 있기 때문입니다. 이제 longest 함수 맥락에서 라이프타임 주석들이 서로 어떻게 관련되는지 봅시다.

함수 시그니처에서의 라이프타임

함수 시그니처에서 라이프타임 주석을 사용하려면, 함수 이름과 매개변수 목록 사이의 꺾쇠 괄호 안에 제네릭 라이프타임 매개변수를 선언해야 합니다. 이는 제네릭 타입 매개변수를 선언할 때와 같습니다.

우리는 이 시그니처가 다음 제약을 표현하길 원합니다. “반환되는 참조는 두 매개변수가 모두 유효한 동안에만 유효하다.” 즉, 매개변수의 라이프타임과 반환값 라이프타임 사이 관계를 표현하고 싶은 것입니다. 이를 위해 라이프타임 이름을 'a 로 정하고, 모든 참조에 같은 이름을 붙입니다. 목록 10-21을 보세요.

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}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-21: 시그니처 안의 모든 참조가 같은 라이프타임 'a 를 가져야 함을 지정한 longest 함수

이 코드는 목록 10-19의 main 함수와 함께 사용하면 정상적으로 컴파일되고, 원하는 결과를 출력합니다.

이제 함수 시그니처는 러스트에게 “어떤 라이프타임 'a 에 대해, 함수는 그 라이프타임 이상 살아 있는 문자열 슬라이스 두 개를 받고, 반환값 역시 최소한 'a 동안 살아 있는 문자열 슬라이스다”라고 말하고 있습니다. 실제로는, longest 함수가 반환하는 참조의 라이프타임은 함수 인수들이 가리키는 두 값의 라이프타임 중 더 짧은 쪽과 같다는 뜻입니다. 이것이 바로 우리가 러스트가 이 코드를 분석할 때 사용하길 원하는 관계입니다.

기억해야 할 점은, 우리가 이 함수 시그니처에 라이프타임 매개변수를 적을 때 실제로 들어오고 나가는 값들의 라이프타임을 바꾸는 것이 아니라는 것입니다. 단지 대여 검사기가 이 제약을 지키지 않는 값을 거부할 수 있게 규칙을 명시하는 것뿐입니다. 또한 longest 함수는 xy 가 정확히 얼마 동안 살아 있을지를 알 필요도 없습니다. 단지 이 시그니처를 만족시키는 어떤 스코프가 'a 로 대입될 수 있기만 하면 됩니다.

함수에 라이프타임을 주석으로 적을 때는 함수 본문이 아니라 시그니처에 적습니다. 라이프타임 주석은 시그니처의 타입처럼 함수의 계약 일부가 됩니다. 함수 시그니처가 이런 라이프타임 계약을 담고 있으면, 러스트 컴파일러의 분석도 더 단순해질 수 있습니다. 함수 주석 방식이나 호출 방식에 문제가 있으면, 컴파일러는 코드의 어느 부분과 어떤 제약이 문제인지 더 정확하게 가리킬 수 있습니다. 만약 러스트 컴파일러가 우리가 의도한 라이프타임 관계를 더 많이 추측해야 했다면, 오류가 생겼을 때 원인에서 훨씬 떨어진 다른 지점을 겨우 지목할 수도 있었을 것입니다.

구체적인 참조를 longest 에 넘길 때, 'a 에 실제로 대입되는 구체 라이프타임은 xy 의 스코프가 겹치는 부분입니다. 다시 말해 제네릭 라이프타임 'axy 의 라이프타임 중 더 짧은 쪽과 동일한 구체 라이프타임이 됩니다. 그리고 반환 참조에 같은 'a 라이프타임 매개변수를 붙였기 때문에, 반환 참조 역시 xy 라이프타임 중 더 짧은 쪽만큼만 유효합니다.

라이프타임 주석이 longest 함수를 어떻게 제한하는지, 서로 다른 구체 라이프타임을 가진 참조를 넘기며 확인해 봅시다. 목록 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}");
    }
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-22: 구체적인 라이프타임이 서로 다른 String 참조로 longest 함수 사용하기

이 예제에서 string1 은 바깥 스코프 끝까지 유효하고, string2 는 안쪽 스코프 끝까지 유효합니다. 그리고 result 는 안쪽 스코프 끝까지 유효한 무언가를 참조합니다. 이 코드를 실행하면 대여 검사기는 이를 허용하고, 프로그램은 컴파일되어 The longest string is long string is long 을 출력합니다.

이제 result 안의 참조 라이프타임이 두 인수 중 더 짧은 쪽이어야 함을 보여 주는 예를 봅시다. result 변수 선언을 안쪽 스코프 바깥으로 빼되, 실제 값을 대입하는 부분은 string2 가 있는 안쪽 스코프 안에 그대로 두겠습니다. 그리고 result 를 사용하는 println! 도 안쪽 스코프가 끝난 뒤로 옮깁니다. 목록 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}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-23: string2 가 스코프를 벗어난 뒤 result 를 사용하려고 시도하기

이 코드를 컴파일하려 하면 다음 오류가 납니다.

$ 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

이 오류는 println! 문에서 result 가 유효하려면, string2 역시 바깥 스코프 끝까지 유효해야 한다고 알려 줍니다. 러스트는 함수 매개변수와 반환값 라이프타임을 같은 라이프타임 매개변수 'a 로 주석 처리했기 때문에 이 사실을 알고 있습니다.

사람 눈으로 보면, 이 경우 string1string2 보다 길고 따라서 result 가 항상 string1 을 참조할 것이라는 점을 알 수 있습니다. string1 은 아직 스코프를 벗어나지 않았으므로 string1 에 대한 참조는 println! 에서도 유효합니다. 하지만 컴파일러는 이 경우 참조가 유효하다는 사실을 볼 수 없습니다. 우리는 longest 함수가 반환하는 참조의 라이프타임이 들어온 참조들 중 더 짧은 쪽과 같다고 러스트에게 알려 주었기 때문입니다. 그래서 대여 검사기는 목록 10-23의 코드를 무효한 참조 가능성이 있는 것으로 보고 거부합니다.

이제 longest 함수에 넘기는 참조 값과 라이프타임, 그리고 반환 참조 사용 위치를 직접 바꿔 가며 더 많은 실험을 해 보세요. 컴파일하기 전에 대여 검사기를 통과할지 못할지 먼저 가설을 세운 뒤, 실제 결과를 확인해 보면 많은 도움이 됩니다.

관계

어떤 방식으로 라이프타임 매개변수를 적어야 하는지는 함수가 실제로 무엇을 하는지에 달려 있습니다. 예를 들어 longest 구현을 “항상 첫 번째 매개변수만 반환한다”로 바꾼다면, y 매개변수에는 라이프타임을 지정할 필요가 없습니다. 다음 코드는 컴파일됩니다.

Filename: src/main.rs
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 함수를 다음과 같이 구현해 보려는 시도를 생각해 보세요. 컴파일되지 않습니다.

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}");
}

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

문제는 resultlongest 함수 끝에서 스코프를 벗어나 정리된다는 점입니다. 그런데 우리는 함수에서 result 에 대한 참조를 반환하려고 했습니다. 라이프타임 매개변수를 어떻게 적더라도 이 댕글링 참조 문제 자체를 바꿀 수는 없습니다. 러스트는 댕글링 참조를 허용하지 않기 때문입니다. 이런 경우 가장 좋은 해결책은 참조 대신 소유권을 가진 데이터 타입을 반환하여, 그 값을 정리하는 책임을 호출하는 함수 쪽으로 옮기는 것입니다.

결국 라이프타임 문법의 핵심은 함수의 여러 매개변수와 반환값 라이프타임을 서로 연결하는 데 있습니다. 일단 이런 연결이 명시되면, 러스트는 메모리 안전한 연산은 허용하고, 댕글링 포인터를 만들거나 메모리 안전성을 깨뜨릴 연산은 막을 수 있는 충분한 정보를 얻게 됩니다.

구조체 정의에서의 라이프타임

지금까지 우리가 정의한 구조체는 모두 소유 타입만 담고 있었습니다. 구조체 안에 참조를 담도록 정의할 수도 있지만, 그 경우 구조체 정의 안의 모든 참조에 라이프타임 주석을 추가해야 합니다. 목록 10-24는 문자열 슬라이스를 담는 ImportantExcerpt 구조체를 보여 줍니다.

Filename: src/main.rs
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,
    };
}
Listing 10-24: 참조를 담고 있기 때문에 라이프타임 주석이 필요한 구조체

이 구조체는 문자열 슬라이스를 담는 part 라는 필드를 하나 가집니다. 제네릭 데이터 타입 때와 마찬가지로, 구조체 이름 뒤의 꺾쇠 괄호 안에 제네릭 라이프타임 매개변수 이름을 선언해 두고, 그 라이프타임을 구조체 본문 안에서 사용합니다. 이 주석은 ImportantExcerpt 인스턴스가 part 필드 안에 든 참조보다 더 오래 살 수 없음을 뜻합니다.

여기서 main 함수는 novel 이라는 변수 안의 String 첫 문장을 가리키는 ImportantExcerpt 인스턴스를 만듭니다. novel 안의 데이터는 ImportantExcerpt 인스턴스가 만들어지기 전에 이미 존재하며, ImportantExcerpt 가 스코프를 벗어난 뒤에야 novel 도 스코프를 벗어나기 때문에, ImportantExcerpt 안의 참조는 유효합니다.

라이프타임 생략

여러분은 이제 모든 참조가 라이프타임을 가지고 있고, 참조를 사용하는 함수나 구조체는 라이프타임 매개변수를 명시해야 할 수도 있다는 사실을 배웠습니다. 그런데 목록 4-9에서 정의했던 함수는, 목록 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[..]
}

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);
}
Listing 10-25: 매개변수와 반환 타입이 모두 참조인데도 라이프타임 주석 없이 컴파일되었던 목록 4-9의 함수

이 함수가 라이프타임 주석 없이 컴파일되는 이유는 역사적인 배경이 있습니다. 초기 버전의 러스트(1.0 이전)에서는 이런 코드가 컴파일되지 않았습니다. 그 당시에는 모든 참조에 대해 라이프타임을 명시해야 했습니다. 당시 이 함수 시그니처는 다음처럼 적었을 것입니다.

fn first_word<'a>(s: &'a str) -> &'a str {

러스트 코드를 많이 쓰다 보니, 러스트 팀은 프로그래머들이 특정 상황에서 같은 라이프타임 주석을 반복해서 계속 적고 있다는 사실을 발견했습니다. 이 상황들은 예측 가능했고, 몇 가지 결정적인 패턴을 따르고 있었습니다. 그래서 개발자들은 그런 패턴을 컴파일러에 직접 집어넣어, 대여 검사기가 이런 경우에는 라이프타임을 추론하게 하고 더 이상 명시적 주석이 필요 없게 만들었습니다.

이 역사 이야기가 중요한 이유는, 앞으로도 이런 결정 가능한 패턴이 더 발견되면 컴파일러에 추가될 수 있기 때문입니다. 미래에는 라이프타임 주석이 지금보다 훨씬 덜 필요해질 수도 있습니다.

러스트의 참조 분석 안에 내장된 이런 패턴들을 라이프타임 생략 규칙(lifetime elision rules) 이라고 합니다. 이것은 프로그래머가 따라야 할 규칙이 아니라, 컴파일러가 확인하는 특정 케이스들의 집합입니다. 코드가 이 케이스에 맞으면 라이프타임을 직접 쓰지 않아도 됩니다.

하지만 생략 규칙이 완전한 추론을 제공하는 것은 아닙니다. 러스트가 규칙을 모두 적용한 뒤에도 참조 라이프타임에 대한 모호함이 남아 있으면, 컴파일러는 남은 참조의 라이프타임을 추측하지 않습니다. 대신 라이프타임 주석을 추가해서 해결하라는 오류를 냅니다.

함수나 메서드 매개변수에 붙는 라이프타임은 입력 라이프타임(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,
    };
}

입력 라이프타임이 두 개 있기 때문에, 러스트는 먼저 첫 번째 생략 규칙을 적용해 &selfannouncement 각각에 자신만의 라이프타임을 부여합니다. 그다음 매개변수 중 하나가 &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 함수를 확장한 것으로, 두 문자열 슬라이스 중 더 긴 것을 반환합니다. 여기에 ann 이라는 추가 매개변수가 있는데, 이것은 where 절에 적힌 대로 Display 트레이트를 구현하는 어떤 타입이든 될 수 있는 제네릭 타입 T 입니다. 이 추가 매개변수는 {} 로 출력될 것이므로 Display 트레이트 바운드가 필요합니다. 라이프타임 역시 제네릭의 한 종류이므로, 라이프타임 매개변수 'a 와 제네릭 타입 매개변수 T 선언은 함수 이름 뒤 같은 꺾쇠 괄호 목록 안에 함께 들어갑니다.

정리

이 장에서는 정말 많은 내용을 다뤘습니다! 이제 여러분은 제네릭 타입 매개변수, 트레이트, 트레이트 바운드, 그리고 제네릭 라이프타임 매개변수를 알게 되었습니다. 덕분에 반복 없이, 다양한 상황에서 동작하는 코드를 작성할 준비가 되었습니다. 제네릭 타입 매개변수는 코드를 여러 타입에 적용하게 해 주고, 트레이트와 트레이트 바운드는 그런 타입들이 코드에 필요한 동작을 갖는지 보장합니다. 또한 라이프타임 주석을 사용하면, 이 유연한 코드가 댕글링 참조를 만들지 않도록 보장할 수 있습니다. 그리고 이 분석은 전부 컴파일 시점에 이루어지므로 런타임 성능에는 영향을 주지 않습니다!

믿기 어렵겠지만, 이 장의 주제들에는 아직 배울 것이 더 많습니다. 18장에서는 트레이트 객체를 다루는데, 이것도 트레이트를 사용하는 또 다른 방법입니다. 라이프타임 주석 역시 훨씬 더 복잡한 시나리오가 존재하지만, 그런 상황은 아주 고급 경우에만 필요합니다. 그럴 때는 Rust Reference 를 읽는 것이 좋습니다. 하지만 그 전에, 러스트에서 테스트를 작성해 코드가 기대한 대로 동작하는지 확인하는 방법을 배우겠습니다.