Victoree's Blog

[3] 라이프 타임과 참조자 유효화 본문

Rust

[3] 라이프 타임과 참조자 유효화

victoree 2023. 2. 16. 20:21
728x90

라이프 타임과 참조자 유효화

보통의 언어에서 참조자들의 라이프타임은 암묵적이고 추론을 통해 알게되는데, 러스트에서는 변수의 타입이 여러가지가 가능한 것처럼 참조자들의 라이프 타임 역시 제네릭 라이프타임 파라미터를 작성하여 이 관계들이 명시적이고, 런타임에 실제 참조자가 확실히 유효하도록 확신할 수 있도록 한다.

댕글링 참조자 방지

라이프 타임의 주 목적은 댕글링 레퍼런스를 방지하는 것이다.
대부분의 코드내의 문제는 댕글링 참조자를 만드는 시도, 사용가능한 라이프 타임들의 불일치이다.

{
    let r;

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

    println!("r: {}", r);
}
// 결과
error: `x` does not live long enough
   |
6  |         r = &x;
   |              - borrow occurs here
7  |     }
   |     ^ `x` dropped here while still borrowed
...
10 | }
   | - borrowed value needs to live until here

x의 스코프 범위보다 r의 스코프 범위가 더 넓어 오래 살게되고, r은 x 가 스코프 영역을 벗어나게 되었을 때 할당이 해제되는 메모리를 참조하게 된다. 러스트에서는 이를 Borrow checker(빌림 검사기)를 통해 검증한다.

Borrow checker(빌림 검사기)

러스트는 컴파일 타임에서 각 변수의 라이프 타임을 비교하여, 본인의 라이프타임보다 작은 라이프타임을 가진 오브젝트를 참조하게 될때 이를 거부한다.

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

함수나 메소드의 파라미터에 대한 라이프타임을 입력 라이프타임(input lifetime) 이라고 하며, 반환 값에 대한 라이프타임을 출력 라이프타임(output lifetime) 이라고 한다.

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
    }
}
// 결과 : error[E0106]: missing lifetime specifier

반환되는 참조자가 x를 참조할지, y를 참조할지 알 수 없는 상황이기 때문에, 제네릭 라이프타임 파라미터를 추가하여 Borrow Checker가 이를 분석할 수 있도록 해야한다. (Borrow Checker 역시 x와 y의 라이프타임이 반환 값의 라이프타임과 어떻게 연관되어있는지 알지 못하기 때문)

라이프 타임 명시가 연관된 참조자가 얼마나 오래 살게되는지를 바꾸지는 않는다. 함수의 시그니처가 제네릭 타입 파라미터를 특정할 때, 이 함수가 어떤 타입을 허용하게되는 것과 같이 함수의 시그니처가 제너릭 라이프타임 파라미터를 특정하게 되면, 이 함수는 어떠한 라이프타임을 가진 참조자라도 허용할 수 있다.

&i32        // a reference
&'a i32     // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime

만일 라이프타임 'a를 가지고 있는 i32에 대한 참조자인 first를 파라미터로, 그리고 또한 라이프타임 'a를 가지고 있는 i32에 대한 또 다른 참조자인 second를 또 다른 파라미터로 가진 함수가 있다면, 이 두 개의 같은 이름을 가진 라이프타임 명시는 참조자 first와 second가 돌다 동일한 제네릭 라이프타임만큼 살아야 한다는 것을 가리킨다.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

시그니처 내의 모든 참조자들이 동일한 라이프타임 'a를 가지고 있어야 함을 특정한 longest 함수 정의

스코프는 언제나 중첩되기 때문에, 이것이 제네릭 라이프타임 'a이다라고 말하는 또 다른 방법은 x와 y의 라이프타임 중에서 더 작은 쪽과 동일한 구체적인 라이프타임을 구하는 것일 겁니다. 반환되는 참조자에 대해서도 같은 라이프타임 파라미터인 'a를 명시했으므로, 반환되는 참조자도 x 와 y의 라이프타임 중 짧은 쪽만큼은 길게 유효함을 보장할 것입니다.

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);
}
// result의 변수 선언이 내부 스코프 영역 밖에 되었지만, longest 함수에서 반환하는 참조자의 라이프 영역이 string2의 라이프타임과 같기 때문에, println! 구문은 버그를 낸다.

모든 참조자들은 명시적인 라이프타임이 필요한가?

참조자에 대한 러스트의 분석 기능 내에 프로그래밍된 패턴들을 일컬어 라이프타임 생략 규칙(lifetime elision rules) 이라고 한다. 이들은 프로그래머가 따라야 하는 규칙들이 아니고, 이 규칙들은 컴파일러가 고려할 특정한 경우의 집합이고, 여러분의 코드가 이러한 경우에 들어맞으면, 여러분은 명시적으로 라이프타임을 작성할 필요가 없어집니다.

라이프타임 생략 규칙

  1. 참조자인 각각의 파라미터는 고유한 라이프타임 파라미터를 갖습니다. 바꿔 말하면, 하나의 파라미터를 갖는 함수는 하나의 라이프타임 파라미터를 갖고: fn foo<'a>(x: &'a i32), 두 개의 파라미터를 갖는 함수는 두 개의 라이프타임 파라미터를 따로 갖고: fn foo<'a, 'b>(x: &'a i32, y: &'b i32), 이와 같은 식입니다.

  2. 만일 정확히 딱 하나의 라이프타임 파라미터만 있다면, 그 라이프타임이 모든 출력 라이프타임 파라미터들에 대입됩니다: fn foo<'a>(x: &'a i32) -> &'a i32.

  3. 만일 여러 개의 입력 라이프타임 파라미터가 있는데, 메소드라서 그중 하나가 &self 혹은 &mut self라고 한다면, self의 라이프타임이 모든 출력 라이프타임 파라미터에 대입됩니다. 이는 메소드의 작성을 더욱 멋지게 만들어줍니다.

정적 라이프타임

'static 라이프 타임은 프로그램의 전체 생애주기를 가리킨다. 모든 스트링 리터럴은 'static 라이프 타임을 가지고 있다.

let s: &'static str = "I have a static lifetime.";

이 스트링의 텍스트는 여러분의 프로그램의 바이너리 내에 직접 저장되며 여러분 프로그램의 바이너리는 항상 이용이 가능하다.

제네릭 타입 파라미터, 트레잇 바운드, 라이프타임을 함께 쓴 코드 예시

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
    }
}

정리

  1. 러스트는 컴파일 타임에서 각 변수의 라이프 타임을 비교하여, 본인의 라이프타임보다 작은 라이프타임을 가진 오브젝트를 참조하게 될때 이를 거부한다.
  2. 라이프 타임 명시가 연관된 참조자가 얼마나 오래 살게되는지를 바꾸지는 않는다.
  3. 시그니처 내의 모든 참조자들이 동일한 라이프타임 'a를 가지고 있어야 하며, 반환 참조자 역시 이와 동일한 라이프타임을 가지게 된다.
  4. 스코프는 중첩되기 때문에, 각 파라미터의 라이프타임 중 가장 짧은 라이프타임만큼의 유효함을 보장한다.
728x90
Comments