수명을 이용한 참조 유효성 검증

수명(lifetime)은 이미 사용하고 있는 또 다른 종류의 제네릭이다. 타입이 원하는 동작을 보장하는 것과 달리, 수명은 참조가 필요한 동안 유효한지 확인한다.

4장의 “참조와 빌림” 섹션에서 다루지 않은 한 가지 세부 사항은, Rust의 모든 참조는 _수명_을 가진다는 점이다. 수명은 해당 참조가 유효한 범위를 의미한다. 대부분의 경우, 타입이 추론되듯이 수명도 암묵적으로 추론된다. 여러 타입이 가능한 경우에만 타입을 명시해야 하듯이, 참조의 수명이 서로 여러 방식으로 연관될 가능성이 있는 경우에만 수명을 명시해야 한다. Rust는 제네릭 수명 매개변수를 사용해 이러한 관계를 명시하도록 요구하며, 이는 런타임에 사용되는 실제 참조가 확실히 유효하도록 보장하기 위함이다.

수명을 명시하는 개념은 대부분의 다른 프로그래밍 언어에는 존재하지 않기 때문에 익숙하지 않을 수 있다. 이 장에서 수명의 모든 내용을 다루지는 않지만, 수명 문법을 접할 수 있는 일반적인 경우를 살펴보며 개념에 익숙해질 수 있도록 하겠다.

라이프타임을 통한 댕글링 참조 방지

라이프타임의 주요 목적은 **댕글링 참조(dangling references)**를 방지하는 것이다. 댕글링 참조는 프로그램이 의도한 데이터가 아닌 다른 데이터를 참조하게 만드는 문제를 일으킨다. 아래의 예제 코드를 살펴보자. 이 코드는 외부 스코프와 내부 스코프를 가지고 있다.

fn main() {
    let r;

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

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

참고: 예제 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가 “충분히 오래 살지 못했다“고 말한다. 그 이유는 내부 스코프가 7번 줄에서 종료될 때 x가 스코프를 벗어나기 때문이다. 하지만 r은 여전히 외부 스코프에서 유효하다. 외부 스코프가 더 크기 때문에 r은 “더 오래 산다“고 표현할 수 있다. 만약 Rust가 이 코드를 허용했다면, rx가 스코프를 벗어날 때 해제된 메모리를 참조하게 될 것이고, r을 사용해 어떤 작업을 시도해도 제대로 동작하지 않을 것이다. 그렇다면 Rust는 어떻게 이 코드가 유효하지 않다고 판단할까? 바로 **빌림 검사기(borrow checker)**를 사용한다.

대여 검사기

Rust 컴파일러는 _대여 검사기_를 통해 스코프를 비교하여 모든 대여가 유효한지 확인한다. 목록 10-17은 목록 10-16과 동일한 코드이지만, 변수의 라이프타임을 보여주는 주석이 추가되었다.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+
Listing 10-17: rx의 라이프타임을 각각 'a'b로 명시한 예제

여기서 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}");   //   |       |
                          // --+       |
}                         // ----------+
Listing 10-18: 참조보다 데이터의 라이프타임이 더 길어 유효한 참조가 된 예제

여기서 x'b 라이프타임을 가지며, 이 경우 'a보다 크다. 이는 rx를 참조할 수 있음을 의미한다. Rust는 x가 유효한 동안 r의 참조가 항상 유효할 것임을 알기 때문이다.

이제 참조의 라이프타임이 어디에 위치하는지, 그리고 Rust가 라이프타임을 분석하여 참조가 항상 유효한지 확인하는 방법을 알았으니, 함수의 매개변수와 반환값의 제네릭 라이프타임을 탐구해보자.

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

두 문자열 슬라이스 중 더 긴 것을 반환하는 함수를 작성해 보자. 이 함수는 두 문자열 슬라이스를 받아서 하나의 문자열 슬라이스를 반환한다. 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 함수가 매개변수의 소유권을 가지지 않게 하기 위해서다. 리스트 10-19에서 사용한 매개변수가 왜 적합한지에 대한 더 자세한 설명은 4장의 “문자열 슬라이스를 매개변수로 사용하기”를 참고하자.

리스트 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

도움말 텍스트를 보면 반환 타입에 제네릭 라이프타임 매개변수가 필요하다고 나와 있다. Rust는 반환되는 참조가 x를 가리키는지 y를 가리키는지 알 수 없기 때문이다. 사실 우리도 알 수 없다. 왜냐하면 함수 본문의 if 블록은 x에 대한 참조를 반환하고, else 블록은 y에 대한 참조를 반환하기 때문이다!

이 함수를 정의할 때, 어떤 구체적인 값이 함수에 전달될지 알 수 없으므로 if 케이스가 실행될지 else 케이스가 실행될지 알 수 없다. 또한 전달된 참조의 구체적인 라이프타임도 알 수 없으므로 리스트 10-17과 10-18에서 했던 것처럼 스코프를 보고 반환된 참조가 항상 유효한지 확인할 수 없다. 빌림 검사기(borrow checker)도 이를 결정할 수 없다. 왜냐하면 xy의 라이프타임이 반환 값의 라이프타임과 어떻게 관련되는지 알 수 없기 때문이다. 이 오류를 해결하기 위해, 참조 간의 관계를 정의하는 제네릭 라이프타임 매개변수를 추가해 빌림 검사기가 분석을 수행할 수 있도록 해야 한다.

라이프타임 주석 구문

라이프타임 주석은 참조의 수명을 변경하지 않는다. 대신, 여러 참조 간의 라이프타임 관계를 설명하며, 실제 라이프타임에는 영향을 미치지 않는다. 함수가 제네릭 타입 매개변수를 지정할 때 어떤 타입이든 받아들일 수 있는 것처럼, 함수는 제네릭 라이프타임 매개변수를 지정하여 어떤 라이프타임을 가진 참조든 받아들일 수 있다.

라이프타임 주석은 약간 독특한 구문을 가지고 있다. 라이프타임 매개변수의 이름은 반드시 아포스트로피(')로 시작해야 하며, 일반적으로 모두 소문자로 짧게 작성한다. 대부분의 사람들은 첫 번째 라이프타임 주석으로 'a라는 이름을 사용한다. 라이프타임 매개변수 주석은 참조의 & 뒤에 위치하며, 주석과 참조의 타입을 공백으로 구분한다.

다음은 몇 가지 예제이다. 라이프타임 매개변수가 없는 i32에 대한 참조, 'a라는 라이프타임 매개변수가 있는 i32에 대한 참조, 그리고 'a 라이프타임을 가진 i32에 대한 가변 참조이다.

&i32        // 참조
&'a i32     // 명시적 라이프타임이 있는 참조
&'a mut i32 // 명시적 라이프타임이 있는 가변 참조

단일 라이프타임 주석은 그 자체로 큰 의미를 가지지 않는다. 주석은 여러 참조의 제네릭 라이프타임 매개변수가 서로 어떻게 관련되는지 Rust에게 알려주기 위한 것이기 때문이다. 이제 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 함수와 함께 사용할 때 컴파일되어 원하는 결과를 생성해야 한다.

이제 함수 시그니처는 Rust에게 특정 라이프타임 'a에 대해, 함수가 두 매개변수를 받으며, 이 두 매개변수는 최소한 라이프타임 'a만큼 살아있는 문자열 슬라이스임을 알려준다. 또한 함수 시그니처는 함수에서 반환된 문자열 슬라이스가 최소한 라이프타임 'a만큼 살아있음을 알려준다. 실제로 이는 longest 함수가 반환한 참조의 라이프타임이 함수 인수로 전달된 값들의 라이프타임 중 더 짧은 것과 동일함을 의미한다. 이러한 관계는 Rust가 이 코드를 분석할 때 사용하길 원하는 것이다.

기억해야 할 점은, 이 함수 시그니처에서 라이프타임 매개변수를 지정할 때, 전달되거나 반환된 값의 라이프타임을 변경하는 것이 아니라는 점이다. 오히려, 이 제약을 따르지 않는 값을 차단하도록 borrow checker에 지시하는 것이다. longest 함수는 xy가 정확히 얼마나 오래 살아있는지 알 필요는 없으며, 이 시그니처를 만족할 수 있는 어떤 스코프가 'a로 대체될 수 있음을 알면 된다.

함수에서 라이프타임을 주석 처리할 때, 주석은 함수 시그니처에 들어가며 함수 본문에는 들어가지 않는다. 라이프타임 주석은 시그니처의 타입과 마찬가지로 함수의 계약의 일부가 된다. 함수 시그니처에 라이프타임 계약이 포함되면 Rust 컴파일러가 수행하는 분석이 더 단순해질 수 있다. 함수가 주석 처리된 방식이나 호출된 방식에 문제가 있다면, 컴파일러 오류가 우리 코드의 특정 부분과 제약을 더 정확히 지적할 수 있다. 만약 Rust 컴파일러가 라이프타임 간의 관계에 대해 더 많은 추론을 했다면, 컴파일러는 문제의 원인에서 멀리 떨어진 코드 사용 부분만을 지적할 수 있었을 것이다.

longest에 구체적인 참조를 전달할 때, 'a로 대체되는 구체적인 라이프타임은 x의 스코프와 y의 스코프가 겹치는 부분이다. 즉, 제네릭 라이프타임 '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는 내부 스코프가 끝날 때까지 유효한 무언가를 참조한다. 이 코드를 실행하면 borrow checker가 승인할 것이며, 컴파일되어 The longest string is long string is long을 출력할 것이다.

다음으로, result의 참조 라이프타임이 두 인수의 더 짧은 라이프타임이어야 함을 보여주는 예제를 시도해보자. 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

오류는 resultprintln! 문에서 유효하려면 string2가 외부 스코프가 끝날 때까지 유효해야 함을 보여준다. Rust는 함수 매개변수와 반환 값의 라이프타임을 동일한 라이프타임 매개변수 'a로 주석 처리했기 때문에 이를 알고 있다.

우리는 이 코드를 보고 string1string2보다 길기 때문에 resultstring1을 참조할 것임을 알 수 있다. string1은 아직 스코프를 벗어나지 않았기 때문에 string1에 대한 참조는 println! 문에서 여전히 유효할 것이다. 그러나 컴파일러는 이 경우 참조가 유효함을 알 수 없다. 우리는 Rust에게 longest 함수가 반환한 참조의 라이프타임이 전달된 참조의 라이프타임 중 더 짧은 것과 동일하다고 알렸다. 따라서 borrow checker는 리스트 10-23의 코드를 유효하지 않은 참조를 가질 가능성이 있다고 판단하여 허용하지 않는다.

longest 함수에 전달된 참조의 값과 라이프타임을 다양하게 변경하고, 반환된 참조가 어떻게 사용되는지에 대한 실험을 더 설계해보자. 컴파일하기 전에 실험이 borrow checker를 통과할지에 대한 가설을 세우고, 그 후에 맞는지 확인해보자!

수명(Lifetime) 개념으로 생각하기

함수에서 수명 매개변수를 어떻게 지정해야 하는지는 함수가 무엇을 하는지에 따라 달라진다. 예를 들어, 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나 반환 값의 수명과 아무런 관련이 없기 때문이다.

함수에서 참조를 반환할 때, 반환 타입의 수명 매개변수는 매개변수 중 하나의 수명 매개변수와 일치해야 한다. 만약 반환된 참조가 매개변수 중 하나를 가리키지 않는다면, 함수 내부에서 생성된 값을 가리켜야 한다. 그러나 이 경우 함수가 끝나면 값이 스코프를 벗어나게 되므로, 이 참조는 댕글링 참조(dangling reference)가 된다. 다음은 컴파일되지 않는 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에 대한 참조를 반환하려고 한다. 댕글링 참조를 변경할 수 있는 수명 매개변수를 지정할 방법이 없으며, Rust는 댕글링 참조를 생성하는 것을 허용하지 않는다. 이 경우, 참조 대신 소유된 데이터 타입을 반환하는 것이 가장 좋은 해결책이다. 그러면 호출 함수가 값을 정리할 책임을 지게 된다.

결국, 수명 구문은 함수의 다양한 매개변수와 반환 값의 수명을 연결하는 것이다. 이들이 연결되면, Rust는 메모리 안전한 작업을 허용하고 댕글링 포인터를 생성하거나 메모리 안전을 위반하는 작업을 금지할 수 있는 충분한 정보를 갖게 된다.

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

지금까지 정의한 구조체는 모두 소유권을 가진 타입을 담고 있었다. 하지만 구조체가 참조를 담도록 정의할 수도 있다. 이 경우, 구조체 정의 내의 모든 참조에 라이프타임 주석을 추가해야 한다. 리스트 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 인스턴스가 생성되기 전에 존재한다. 또한, novelImportantExcerpt가 스코프를 벗어난 후에야 스코프를 벗어나므로, 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에서 정의한 함수로, 매개변수와 반환 타입이 참조임에도 수명을 명시하지 않았음

이 함수가 수명을 명시하지 않고도 컴파일되는 이유는 역사적 배경 때문이다. Rust 초기 버전(1.0 이전)에서는 모든 참조에 명시적인 수명이 필요했기 때문에 이 코드는 컴파일되지 않았다. 당시에는 함수 시그니처가 다음과 같이 작성되었다:

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

Rust 코드를 많이 작성한 후, Rust 팀은 개발자들이 특정 상황에서 동일한 수명을 반복적으로 명시하는 것을 발견했다. 이러한 상황은 예측 가능했고 몇 가지 결정론적인 패턴을 따랐다. 개발자들은 이러한 패턴을 컴파일러 코드에 추가해, 빌린 값 검사기가 이러한 상황에서 수명을 추론하도록 했고, 명시적인 수명이 필요 없게 되었다.

이 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의 수명을 모든 출력 수명 매개변수에 할당한다는 것이다. 이 규칙은 메서드를 읽고 쓰기 훨씬 쉽게 만든다. 왜냐하면 더 적은 기호가 필요하기 때문이다.

이제 컴파일러가 되어 리스트 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의 코드를 컴파일하려고 할 때 오류가 발생한 것이다: 컴파일러는 수명 생략 규칙을 적용했지만 여전히 시그니처의 모든 참조의 수명을 알 수 없었다.

세 번째 규칙은 실제로 메서드 시그니처에만 적용되므로, 다음으로는 메서드 시그니처에서 수명을 살펴보고, 세 번째 규칙이 왜 메서드 시그니처에서 수명을 자주 명시하지 않아도 되는지 알아보자.

메서드 정의에서의 수명 주석

구조체에 수명 주석을 사용해 메서드를 구현할 때, 일반적인 타입 매개변수와 동일한 문법을 사용한다. 수명 매개변수를 선언하고 사용하는 위치는 해당 수명이 구조체 필드와 관련이 있는지, 아니면 메서드 매개변수와 반환값과 관련이 있는지에 따라 달라진다.

구조체 필드의 수명 이름은 항상 impl 키워드 뒤에 선언하고, 구조체 이름 뒤에 사용한다. 이는 해당 수명이 구조체 타입의 일부이기 때문이다.

impl 블록 내부의 메서드 시그니처에서 참조는 구조체 필드의 참조 수명에 묶일 수도 있고, 독립적일 수도 있다. 또한, 수명 생략 규칙에 따라 메서드 시그니처에서 수명 주석이 필요하지 않은 경우가 많다. Listing 10-24에서 정의한 ImportantExcerpt 구조체를 사용한 예제를 살펴보자.

먼저, level이라는 메서드를 사용해 보자. 이 메서드는 self에 대한 참조만 매개변수로 받고, 반환값은 i32 타입으로, 어떤 것에 대한 참조도 아니다:

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는 첫 번째 수명 생략 규칙을 적용해 &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를 참고해야 한다. 하지만 다음으로는 Rust에서 테스트를 작성하는 방법을 배우게 될 것이다. 이를 통해 코드가 의도한 대로 동작하는지 확인할 수 있다.